mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-11 09:04:26 +00:00
Compare commits
2 Commits
kit-pr
...
opencode/h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70eaf3545e | ||
|
|
664f4c0a68 |
@@ -35,8 +35,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -35,8 +35,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -35,8 +35,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -35,8 +35,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -35,8 +35,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -35,8 +35,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
141
README.vi.md
141
README.vi.md
@@ -1,141 +0,0 @@
|
||||
<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">Trợ lý lập trình AI mã nguồn mở.</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.bs.md">Bosanski</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> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
---
|
||||
|
||||
### Cài đặt
|
||||
|
||||
```bash
|
||||
# YOLO
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Các trình quản lý gói (Package managers)
|
||||
npm i -g opencode-ai@latest # hoặc bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật)
|
||||
brew install opencode # macOS và Linux (công thức brew chính thức, ít cập nhật hơn)
|
||||
sudo pacman -S opencode # Arch Linux (Bản ổn định)
|
||||
paru -S opencode-bin # Arch Linux (Bản mới nhất từ AUR)
|
||||
mise use -g opencode # Mọi hệ điều hành
|
||||
nix run nixpkgs#opencode # hoặc github:anomalyco/opencode cho nhánh dev mới nhất
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Hãy xóa các phiên bản cũ hơn 0.1.x trước khi cài đặt.
|
||||
|
||||
### Ứng dụng Desktop (BETA)
|
||||
|
||||
OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download).
|
||||
|
||||
| Nền tảng | Tải xuống |
|
||||
| --------------------- | ------------------------------------- |
|
||||
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||
| Linux | `.deb`, `.rpm`, hoặc AppImage |
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask opencode-desktop
|
||||
# Windows (Scoop)
|
||||
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||
```
|
||||
|
||||
#### Thư mục cài đặt
|
||||
|
||||
Tập lệnh cài đặt tuân theo thứ tự ưu tiên sau cho đường dẫn cài đặt:
|
||||
|
||||
1. `$OPENCODE_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh
|
||||
2. `$XDG_BIN_DIR` - Đường dẫn tuân thủ XDG Base Directory Specification
|
||||
3. `$HOME/bin` - Thư mục nhị phân tiêu chuẩn của người dùng (nếu tồn tại hoặc có thể tạo)
|
||||
4. `$HOME/.opencode/bin` - Mặc định dự phòng
|
||||
|
||||
```bash
|
||||
# Ví dụ
|
||||
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
### Agents (Đại diện)
|
||||
|
||||
OpenCode bao gồm hai agent được tích hợp sẵn mà bạn có thể chuyển đổi bằng phím `Tab`.
|
||||
|
||||
- **build** - Agent mặc định, có toàn quyền truy cập cho công việc lập trình
|
||||
- **plan** - Agent chỉ đọc dùng để phân tích và khám phá mã nguồn
|
||||
- Mặc định từ chối việc chỉnh sửa tệp
|
||||
- Hỏi quyền trước khi chạy các lệnh bash
|
||||
- Lý tưởng để khám phá các codebase lạ hoặc lên kế hoạch thay đổi
|
||||
|
||||
Ngoài ra còn có một subagent **general** dùng cho các tìm kiếm phức tạp và tác vụ nhiều bước.
|
||||
Agent này được sử dụng nội bộ và có thể gọi bằng cách dùng `@general` trong tin nhắn.
|
||||
|
||||
Tìm hiểu thêm về [agents](https://opencode.ai/docs/agents).
|
||||
|
||||
### Tài liệu
|
||||
|
||||
Để biết thêm thông tin về cách cấu hình OpenCode, [**hãy truy cập tài liệu của chúng tôi**](https://opencode.ai/docs).
|
||||
|
||||
### Đóng góp
|
||||
|
||||
Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hướng dẫn đóng góp](./CONTRIBUTING.md) trước khi gửi pull request.
|
||||
|
||||
### Xây dựng trên nền tảng OpenCode
|
||||
|
||||
Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào.
|
||||
|
||||
### Các câu hỏi thường gặp (FAQ)
|
||||
|
||||
#### OpenCode khác biệt thế nào so với Claude Code?
|
||||
|
||||
Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính:
|
||||
|
||||
- 100% mã nguồn mở
|
||||
- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng.
|
||||
- Hỗ trợ LSP ngay từ đầu
|
||||
- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa.
|
||||
- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng.
|
||||
|
||||
---
|
||||
|
||||
**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -35,8 +34,7 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
69
bun.lock
69
bun.lock
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -47,7 +47,7 @@
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"ghostty-web": "0.4.0",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
@@ -76,7 +76,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -110,7 +110,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -137,7 +137,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -161,7 +161,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -185,7 +185,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -218,7 +218,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -248,7 +248,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -277,7 +277,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -293,7 +293,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -351,7 +351,7 @@
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||
"fuzzysort": "3.1.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
@@ -373,7 +373,6 @@
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
@@ -396,11 +395,10 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
@@ -409,7 +407,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -429,7 +427,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -440,7 +438,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -475,7 +473,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -506,7 +504,6 @@
|
||||
"virtua": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -522,7 +519,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -533,7 +530,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -602,8 +599,8 @@
|
||||
"ai": "5.0.124",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -2123,8 +2120,6 @@
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
|
||||
"@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
|
||||
@@ -2685,9 +2680,9 @@
|
||||
|
||||
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="],
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-beta.12-a5629fb", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "tsx": "^4.20.6" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-l+p4QOMvPGYBYEE9NBlU7diu+NSlxuOUwi0I7i01Uj1PpfU0NxhPzaks/9q1MDw4FAPP8vdD0dOhoqosKtRWWQ=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
|
||||
"drizzle-orm": ["drizzle-orm@1.0.0-beta.12-a5629fb", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-wyOAgr9Cy9oEN6z5S0JGhfipLKbRRJtQKgbDO9SXGR9swMBbGNIlXkeMqPRrqYQ8k70mh+7ZJ/eVmJ2F7zR3Vg=="],
|
||||
|
||||
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
|
||||
|
||||
@@ -2963,7 +2958,7 @@
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d"],
|
||||
"ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="],
|
||||
|
||||
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||
|
||||
@@ -3241,7 +3236,7 @@
|
||||
|
||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||
|
||||
"isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
|
||||
|
||||
@@ -4591,7 +4586,7 @@
|
||||
|
||||
"when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
|
||||
|
||||
"which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
||||
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
@@ -5207,8 +5202,6 @@
|
||||
|
||||
"app-builder-lib/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
|
||||
|
||||
"app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
"archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
@@ -5271,8 +5264,6 @@
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.12-a5629fb", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-wyOAgr9Cy9oEN6z5S0JGhfipLKbRRJtQKgbDO9SXGR9swMBbGNIlXkeMqPRrqYQ8k70mh+7ZJ/eVmJ2F7zR3Vg=="],
|
||||
|
||||
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
|
||||
|
||||
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
@@ -5397,8 +5388,6 @@
|
||||
|
||||
"node-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
|
||||
|
||||
"node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
|
||||
@@ -5929,8 +5918,6 @@
|
||||
|
||||
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"archiver-utils/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
@@ -6013,8 +6000,6 @@
|
||||
|
||||
"node-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
|
||||
|
||||
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
|
||||
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { spawn } from "node:child_process"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -282,7 +281,7 @@ async function assertOpencodeConnected() {
|
||||
connected = true
|
||||
break
|
||||
} catch (e) {}
|
||||
await sleep(300)
|
||||
await Bun.sleep(300)
|
||||
} while (retry++ < 30)
|
||||
|
||||
if (!connected) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=",
|
||||
"aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=",
|
||||
"aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=",
|
||||
"x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE="
|
||||
"x86_64-linux": "sha256-ZmxeRNy2chc9py4m1iW6B+c/NSccMnVZ0lfni/EMdHw=",
|
||||
"aarch64-linux": "sha256-R+1mxsmAQicerN8ixVy0ff6V8bZ4GH18MHpihvWnaTg=",
|
||||
"aarch64-darwin": "sha256-m+QT20ohlqo9e86qXu67eKthZm6VDRLwlqJ9CNlEV+0=",
|
||||
"x86_64-darwin": "sha256-4GeNPyTT2Hq4rxHGSON23ul5Ud3yFGE0QUVsB03Gidc="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
sessionItemSelector,
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
sessionHeaderSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
titlebarRightSelector,
|
||||
@@ -226,9 +225,9 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||
|
||||
const header = page.locator(sessionHeaderSelector).first()
|
||||
await expect(header).toBeVisible()
|
||||
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||
const scroller = page.locator(".scroll-view__viewport").first()
|
||||
await expect(scroller).toBeVisible()
|
||||
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
@@ -244,7 +243,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
|
||||
if (opened) return menu
|
||||
|
||||
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
|
||||
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click()
|
||||
|
||||
@@ -442,57 +441,6 @@ export async function seedSessionPermission(
|
||||
return { id: result.id }
|
||||
}
|
||||
|
||||
export async function seedSessionTask(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
input: {
|
||||
sessionID: string
|
||||
description: string
|
||||
prompt: string
|
||||
subagentType?: string
|
||||
},
|
||||
) {
|
||||
const text = [
|
||||
"Your only valid response is one task tool call.",
|
||||
`Use this JSON input: ${JSON.stringify({
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
subagent_type: input.subagentType ?? "general",
|
||||
})}`,
|
||||
"Do not output plain text.",
|
||||
"Wait for the task to start and return the child session id.",
|
||||
].join("\n")
|
||||
|
||||
const result = await seed({
|
||||
sdk,
|
||||
sessionID: input.sessionID,
|
||||
prompt: text,
|
||||
timeout: 90_000,
|
||||
probe: async () => {
|
||||
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
|
||||
const part = messages
|
||||
.flatMap((message) => message.parts)
|
||||
.find((part) => {
|
||||
if (part.type !== "tool" || part.tool !== "task") return false
|
||||
if (part.state.input?.description !== input.description) return false
|
||||
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
||||
})
|
||||
|
||||
if (!part) return
|
||||
const id = part.state.metadata?.sessionId
|
||||
if (typeof id !== "string" || !id) return
|
||||
const child = await sdk.session
|
||||
.get({ sessionID: id })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!child?.id) return
|
||||
return { sessionID: id }
|
||||
},
|
||||
})
|
||||
|
||||
if (!result) throw new Error("Timed out seeding task tool")
|
||||
return result
|
||||
}
|
||||
|
||||
export async function seedSessionTodos(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
input: {
|
||||
|
||||
@@ -53,8 +53,6 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
|
||||
|
||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||
|
||||
export const sessionHeaderSelector = "[data-session-title]"
|
||||
|
||||
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
||||
|
||||
export const workspaceItemSelector = (slug: string) =>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { seedSessionTask, withSession } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
|
||||
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const errs: string[] = []
|
||||
const onError = (err: Error) => {
|
||||
errs.push(err.message)
|
||||
}
|
||||
page.on("pageerror", onError)
|
||||
|
||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||
const child = await seedSessionTask(sdk, {
|
||||
sessionID: session.id,
|
||||
description: "Open child session",
|
||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||
})
|
||||
|
||||
try {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const link = page
|
||||
.locator("a.subagent-link")
|
||||
.filter({ hasText: /open child session/i })
|
||||
.first()
|
||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||
await page.waitForTimeout(1000)
|
||||
expect(errs).toEqual([])
|
||||
} finally {
|
||||
page.off("pageerror", onError)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
openSharePopover,
|
||||
withSession,
|
||||
} from "../actions"
|
||||
import { sessionHeaderSelector, sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
|
||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||
|
||||
@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
|
||||
const input = page.locator(sessionHeaderSelector).locator(inlineInputSelector).first()
|
||||
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(renamedTitle)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -57,7 +57,7 @@
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"ghostty-web": "0.4.0",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general"
|
||||
import { SettingsKeybinds } from "./settings-keybinds"
|
||||
import { SettingsProviders } from "./settings-providers"
|
||||
import { SettingsModels } from "./settings-models"
|
||||
import { SettingsMcp } from "./settings-mcp"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
const language = useLanguage()
|
||||
@@ -45,6 +46,10 @@ export const DialogSettings: Component = () => {
|
||||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp">
|
||||
<Icon name="mcp" />
|
||||
{language.t("settings.mcp.title")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,6 +72,9 @@ export const DialogSettings: Component = () => {
|
||||
<Tabs.Content value="models" class="no-scrollbar">
|
||||
<SettingsModels />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="mcp" class="no-scrollbar">
|
||||
<SettingsMcp />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -256,17 +256,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
pendingAutoAccept: false,
|
||||
})
|
||||
|
||||
const buttonsSpring = useSpring(
|
||||
() => (store.mode === "normal" ? 1 : 0),
|
||||
{ visualDuration: 0.2, bounce: 0 },
|
||||
)
|
||||
|
||||
const springFade = (t: number): Record<string, string> => ({
|
||||
opacity: `${t}`,
|
||||
transform: `scale(${0.95 + t * 0.05})`,
|
||||
filter: `blur(${(1 - t) * 2}px)`,
|
||||
"pointer-events": t > 0.5 ? "auto" : "none",
|
||||
})
|
||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||
|
||||
const commentCount = createMemo(() => {
|
||||
if (store.mode === "shell") return 0
|
||||
@@ -1221,9 +1211,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
aria-multiline="true"
|
||||
aria-label={placeholder()}
|
||||
contenteditable="true"
|
||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={store.mode === "normal"}
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
@@ -1264,7 +1254,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="flex items-center gap-1"
|
||||
style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }}
|
||||
style={{
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1276,7 +1268,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={springFade(buttonsSpring())}
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
@@ -1314,7 +1310,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={springFade(buttonsSpring())}
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -1370,7 +1370,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
|
||||
<div
|
||||
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||
style={{ padding: "0 4px 0 8px", ...springFade(1 - buttonsSpring()) }}
|
||||
style={{
|
||||
padding: "0 4px 0 8px",
|
||||
opacity: 1 - buttonsSpring(),
|
||||
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
||||
filter: `blur(${buttonsSpring() * 2}px)`,
|
||||
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
@@ -1389,7 +1395,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -1407,7 +1419,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
style={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
@@ -1436,7 +1454,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: { height: "28px", ...springFade(buttonsSpring()) },
|
||||
style: {
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
},
|
||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||
}}
|
||||
>
|
||||
@@ -1468,7 +1492,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { errorMessage } from "@/pages/layout/helpers"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -66,7 +65,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
|
||||
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
@@ -152,7 +158,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: toastError(err),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
@@ -191,7 +197,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: toastError(err),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
@@ -249,7 +255,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: toastError(err),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
@@ -327,14 +333,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
messageID,
|
||||
})
|
||||
|
||||
batch(() => {
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
addOptimisticMessage()
|
||||
})
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
addOptimisticMessage()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sessionDirectory)
|
||||
@@ -411,7 +412,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: toastError(err),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
restoreCommentItems(commentItems)
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { findLast, same } from "@opencode-ai/util/array"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
|
||||
@@ -288,6 +288,29 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,665 @@
|
||||
import { Component } from "solid-js"
|
||||
import type { Config, McpLocalConfig, McpRemoteConfig, McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { For, Show, createMemo, onMount, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
type Mode = "remote" | "local"
|
||||
type McpMap = NonNullable<Config["mcp"]>
|
||||
type McpEntry = McpMap[string]
|
||||
type McpConfig = McpLocalConfig | McpRemoteConfig
|
||||
type McpState = McpStatus["status"]
|
||||
|
||||
const FEATURED = [
|
||||
{
|
||||
name: "playwright",
|
||||
title: "Playwright",
|
||||
description: "Browser automation tools for testing, scraping, and repros.",
|
||||
icon: "window-cursor",
|
||||
panel: "linear-gradient(135deg, rgba(59, 130, 246, 0.14), rgba(15, 23, 42, 0.04))",
|
||||
glow: "rgba(96, 165, 250, 0.18)",
|
||||
badge: "rgba(37, 99, 235, 0.14)",
|
||||
color: "rgb(37, 99, 235)",
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["npx", "-y", "@playwright/mcp@latest"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "context7",
|
||||
title: "Context7",
|
||||
description: "Fresh framework docs and API references in one remote server.",
|
||||
icon: "code-lines",
|
||||
panel: "linear-gradient(135deg, rgba(14, 165, 233, 0.16), rgba(15, 23, 42, 0.04))",
|
||||
glow: "rgba(56, 189, 248, 0.2)",
|
||||
badge: "rgba(8, 145, 178, 0.14)",
|
||||
color: "rgb(8, 145, 178)",
|
||||
config: {
|
||||
type: "remote",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "linear",
|
||||
title: "Linear",
|
||||
description: "Issue and project workflows from your Linear workspace.",
|
||||
icon: "branch",
|
||||
panel: "linear-gradient(135deg, rgba(124, 58, 237, 0.14), rgba(30, 41, 59, 0.04))",
|
||||
glow: "rgba(139, 92, 246, 0.18)",
|
||||
badge: "rgba(109, 40, 217, 0.14)",
|
||||
color: "rgb(109, 40, 217)",
|
||||
config: {
|
||||
type: "remote",
|
||||
url: "https://mcp.linear.app/sse",
|
||||
oauth: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sentry",
|
||||
title: "Sentry",
|
||||
description: "Error monitoring, traces, and issue triage in one place.",
|
||||
icon: "warning",
|
||||
panel: "linear-gradient(135deg, rgba(14, 116, 144, 0.16), rgba(15, 23, 42, 0.04))",
|
||||
glow: "rgba(6, 182, 212, 0.16)",
|
||||
badge: "rgba(8, 145, 178, 0.14)",
|
||||
color: "rgb(8, 145, 178)",
|
||||
config: {
|
||||
type: "remote",
|
||||
url: "https://mcp.sentry.dev/mcp",
|
||||
oauth: {},
|
||||
},
|
||||
},
|
||||
] satisfies Array<{
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
icon: IconProps["name"]
|
||||
panel: string
|
||||
glow: string
|
||||
badge: string
|
||||
color: string
|
||||
config: McpConfig
|
||||
}>
|
||||
|
||||
const STATUS = {
|
||||
connected: "mcp.status.connected",
|
||||
failed: "mcp.status.failed",
|
||||
needs_auth: "mcp.status.needs_auth",
|
||||
disabled: "mcp.status.disabled",
|
||||
needs_client_registration: "settings.mcp.status.needs_client_registration",
|
||||
} satisfies Record<McpState, string>
|
||||
|
||||
const empty = (mode: Mode = "remote") => ({
|
||||
mode,
|
||||
name: "",
|
||||
url: "",
|
||||
command: "",
|
||||
headers: "",
|
||||
environment: "",
|
||||
timeout: "",
|
||||
})
|
||||
|
||||
const isConfig = (value: McpEntry | undefined): value is McpConfig =>
|
||||
typeof value === "object" && value !== null && "type" in value
|
||||
|
||||
const split = (value: string) =>
|
||||
value
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const parseMap = (value: string, allowColon: boolean) => {
|
||||
const out: Record<string, string> = {}
|
||||
|
||||
for (const line of split(value)) {
|
||||
const eq = line.indexOf("=")
|
||||
const cut = !allowColon ? eq : ([line.indexOf(":"), eq].filter((part) => part > 0).sort((a, b) => a - b)[0] ?? -1)
|
||||
|
||||
if (cut < 1) return { error: line }
|
||||
|
||||
const key = line.slice(0, cut).trim()
|
||||
const item = line.slice(cut + 1).trim()
|
||||
if (!key || !item) return { error: line }
|
||||
out[key] = item
|
||||
}
|
||||
|
||||
return { value: Object.keys(out).length > 0 ? out : undefined }
|
||||
}
|
||||
|
||||
const parseCmd = (value: string) =>
|
||||
(value.match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? []).map((part) => {
|
||||
if (part.startsWith('"') && part.endsWith('"')) return part.slice(1, -1)
|
||||
if (part.startsWith("'") && part.endsWith("'")) return part.slice(1, -1)
|
||||
return part
|
||||
})
|
||||
|
||||
export const SettingsMcp: Component = () => {
|
||||
// TODO: Replace this placeholder with full MCP settings controls.
|
||||
const language = useLanguage()
|
||||
const lang = useLanguage()
|
||||
const sdk = useGlobalSDK()
|
||||
const sync = useGlobalSync()
|
||||
const [state, setState] = createStore({
|
||||
form: empty(),
|
||||
submitting: "",
|
||||
statusLoading: false,
|
||||
status: {} as Record<string, McpStatus>,
|
||||
})
|
||||
|
||||
const busy = createMemo(() => state.submitting.length > 0)
|
||||
|
||||
const items = createMemo(() => {
|
||||
return Object.entries(sync.data.config.mcp ?? {})
|
||||
.filter((item): item is [string, McpConfig] => isConfig(item[1]))
|
||||
.map(([name, config]) => ({ name, config }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
const names = createMemo(() => new Set(items().map((item) => item.name)))
|
||||
|
||||
const spin = () => `${lang.t("common.loading")}${lang.t("common.loading.ellipsis")}`
|
||||
|
||||
const kind = (value: Mode) => {
|
||||
if (value === "remote") return lang.t("settings.mcp.type.remote")
|
||||
return lang.t("settings.mcp.type.local")
|
||||
}
|
||||
|
||||
const fail = (description: string) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: lang.t("common.requestFailed"),
|
||||
description,
|
||||
})
|
||||
}
|
||||
|
||||
const load = () => {
|
||||
setState("statusLoading", true)
|
||||
return sdk.client.mcp
|
||||
.status()
|
||||
.then((x) => {
|
||||
setState("status", x.data ?? {})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
setState("statusLoading", false)
|
||||
})
|
||||
}
|
||||
|
||||
const save = (next: McpMap, job: string, onSuccess: () => void, title: string, description: string) => {
|
||||
const prev = sync.data.config.mcp
|
||||
setState("submitting", job)
|
||||
sync.set("config", "mcp", next)
|
||||
|
||||
sync
|
||||
.updateConfig({ mcp: next })
|
||||
.then(() => {
|
||||
onSuccess()
|
||||
void load()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title,
|
||||
description,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
sync.set("config", "mcp", prev)
|
||||
fail(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
.finally(() => {
|
||||
setState("submitting", "")
|
||||
})
|
||||
}
|
||||
|
||||
const add = (name: string, config: McpConfig, job: string, reset: boolean) => {
|
||||
const key = name.trim()
|
||||
if (!key) {
|
||||
fail(lang.t("settings.mcp.validation.name"))
|
||||
return
|
||||
}
|
||||
|
||||
if (names().has(key)) {
|
||||
fail(lang.t("settings.mcp.validation.duplicate", { name: key }))
|
||||
return
|
||||
}
|
||||
|
||||
const next = {
|
||||
...(sync.data.config.mcp ?? {}),
|
||||
[key]: config,
|
||||
}
|
||||
|
||||
save(
|
||||
next,
|
||||
job,
|
||||
() => {
|
||||
if (!reset) return
|
||||
setState("form", empty(state.form.mode))
|
||||
},
|
||||
lang.t("settings.mcp.toast.added.title"),
|
||||
lang.t("settings.mcp.toast.added.description", { name: key }),
|
||||
)
|
||||
}
|
||||
|
||||
const addForm = () => {
|
||||
if (busy()) return
|
||||
|
||||
const timeout = state.form.timeout.trim()
|
||||
const wait = timeout ? Number(timeout) : undefined
|
||||
if (wait !== undefined && (!Number.isInteger(wait) || wait <= 0)) {
|
||||
fail(lang.t("settings.mcp.validation.timeout"))
|
||||
return
|
||||
}
|
||||
|
||||
if (state.form.mode === "remote") {
|
||||
const url = state.form.url.trim()
|
||||
if (!url) {
|
||||
fail(lang.t("settings.mcp.validation.url"))
|
||||
return
|
||||
}
|
||||
|
||||
const headers = parseMap(state.form.headers, true)
|
||||
if (headers.error) {
|
||||
fail(lang.t("settings.mcp.validation.headers", { line: headers.error }))
|
||||
return
|
||||
}
|
||||
|
||||
add(
|
||||
state.form.name,
|
||||
{
|
||||
type: "remote",
|
||||
url,
|
||||
...(headers.value ? { headers: headers.value } : {}),
|
||||
...(wait ? { timeout: wait } : {}),
|
||||
},
|
||||
"form",
|
||||
true,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const command = parseCmd(state.form.command.trim())
|
||||
if (command.length === 0) {
|
||||
fail(lang.t("settings.mcp.validation.command"))
|
||||
return
|
||||
}
|
||||
|
||||
const environment = parseMap(state.form.environment, false)
|
||||
if (environment.error) {
|
||||
fail(lang.t("settings.mcp.validation.environment", { line: environment.error }))
|
||||
return
|
||||
}
|
||||
|
||||
add(
|
||||
state.form.name,
|
||||
{
|
||||
type: "local",
|
||||
command,
|
||||
...(environment.value ? { environment: environment.value } : {}),
|
||||
...(wait ? { timeout: wait } : {}),
|
||||
},
|
||||
"form",
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
const addFeatured = (item: (typeof FEATURED)[number]) => {
|
||||
if (busy()) return
|
||||
add(item.name, item.config, `featured:${item.name}`, false)
|
||||
}
|
||||
|
||||
const toggle = (name: string, enabled: boolean) => {
|
||||
if (busy()) return
|
||||
const current = (sync.data.config.mcp ?? {})[name]
|
||||
if (!isConfig(current)) return
|
||||
|
||||
const next = {
|
||||
...(sync.data.config.mcp ?? {}),
|
||||
[name]: {
|
||||
...current,
|
||||
enabled,
|
||||
},
|
||||
}
|
||||
|
||||
save(
|
||||
next,
|
||||
`toggle:${name}`,
|
||||
() => undefined,
|
||||
lang.t("settings.mcp.toast.updated.title"),
|
||||
lang.t("settings.mcp.toast.updated.description", {
|
||||
name,
|
||||
state: enabled ? lang.t("settings.mcp.state.enabled") : lang.t("settings.mcp.state.disabled"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const remove = (name: string) => {
|
||||
if (busy()) return
|
||||
|
||||
const next = { ...(sync.data.config.mcp ?? {}) }
|
||||
delete next[name]
|
||||
|
||||
save(
|
||||
next,
|
||||
`remove:${name}`,
|
||||
() => undefined,
|
||||
lang.t("settings.mcp.toast.removed.title"),
|
||||
lang.t("settings.mcp.toast.removed.description", { name }),
|
||||
)
|
||||
}
|
||||
|
||||
const label = (name: string) => {
|
||||
const value = state.status[name]?.status
|
||||
if (!value) return
|
||||
return lang.t(STATUS[value])
|
||||
}
|
||||
|
||||
const issue = (name: string) => {
|
||||
const value = state.status[name]
|
||||
if (!value || !("error" in value)) return
|
||||
return value.error
|
||||
}
|
||||
|
||||
const line = (config: McpConfig) => {
|
||||
if (config.type === "remote") return config.url
|
||||
return config.command.join(" ")
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{lang.t("settings.mcp.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{lang.t("settings.mcp.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong">{lang.t("settings.mcp.section.featured")}</h3>
|
||||
<p class="text-12-regular text-text-weak">{lang.t("settings.mcp.section.featured.description")}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<For each={FEATURED}>
|
||||
{(item) => {
|
||||
const added = () => names().has(item.name)
|
||||
const pending = () => state.submitting === `featured:${item.name}`
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="group relative overflow-hidden rounded-2xl border border-border-weak-base p-4 text-left transition-transform duration-200 disabled:cursor-default"
|
||||
classList={{
|
||||
"hover:-translate-y-0.5": !added() && !busy(),
|
||||
"opacity-60": added(),
|
||||
}}
|
||||
disabled={added() || busy()}
|
||||
onClick={() => addFeatured(item)}
|
||||
>
|
||||
<div class="absolute inset-0" aria-hidden="true">
|
||||
<div class="absolute inset-0" style={{ background: item.panel }} />
|
||||
<div
|
||||
class="absolute -right-6 -top-6 size-24 rounded-full blur-2xl"
|
||||
style={{ background: item.glow }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col gap-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div
|
||||
class="flex size-11 shrink-0 items-center justify-center rounded-2xl border border-border-weak-base"
|
||||
style={{ background: item.badge, color: item.color }}
|
||||
>
|
||||
<Icon name={item.icon} class="size-5" />
|
||||
</div>
|
||||
<span class="rounded-full bg-surface-base px-2.5 py-1 text-11-medium text-text-weak">
|
||||
{kind(item.config.type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-14-medium text-text-strong">{item.title}</span>
|
||||
<Show when={added()}>
|
||||
<span class="rounded-full bg-surface-base px-2 py-0.5 text-11-medium text-text-weak">
|
||||
{lang.t("settings.mcp.featured.added")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={pending()}>
|
||||
<span class="text-11-regular text-text-weak">{spin()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-12-regular leading-5 text-text-weak">{item.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-14-medium text-text-strong">{lang.t("settings.mcp.section.configured")}</h3>
|
||||
<Show when={state.statusLoading}>
|
||||
<span class="text-11-regular text-text-weak">{spin()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-12-regular text-text-weak">{lang.t("settings.mcp.section.configured.description")}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<Show
|
||||
when={items().length > 0}
|
||||
fallback={<div class="py-4 text-14-regular text-text-weak">{lang.t("dialog.mcp.empty")}</div>}
|
||||
>
|
||||
<For each={items()}>
|
||||
{(item) => {
|
||||
const current = () => state.status[item.name]?.status
|
||||
const text = () => label(item.name)
|
||||
const problem = () => issue(item.name)
|
||||
const enabled = () => item.config.enabled !== false
|
||||
const pendingToggle = () => state.submitting === `toggle:${item.name}`
|
||||
const pending = () => state.submitting === `remove:${item.name}`
|
||||
|
||||
return (
|
||||
<div class="flex flex-wrap items-start justify-between gap-4 py-4 border-b border-border-weak-base last:border-none">
|
||||
<div class="min-w-0 flex-1 flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-14-medium text-text-strong">{item.name}</span>
|
||||
<Tag>{kind(item.config.type)}</Tag>
|
||||
<Show when={text()}>
|
||||
<span
|
||||
class="rounded-full bg-surface-base px-2 py-0.5 text-11-medium"
|
||||
classList={{
|
||||
"text-icon-success-base": current() === "connected",
|
||||
"text-icon-warning-base": current() === "needs_auth",
|
||||
"text-icon-critical-base":
|
||||
current() === "failed" || current() === "needs_client_registration",
|
||||
"text-text-weak": current() === "disabled",
|
||||
}}
|
||||
>
|
||||
{text()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<span class="text-12-regular text-text-weak break-all">{line(item.config)}</span>
|
||||
|
||||
<Show when={problem()}>
|
||||
<span class="text-12-regular text-icon-critical-base break-all">{problem()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
|
||||
<div class="flex items-center gap-2 pl-1">
|
||||
<Show when={pendingToggle()}>
|
||||
<span class="text-11-regular text-text-weak">{spin()}</span>
|
||||
</Show>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={busy()}
|
||||
onChange={(next) => toggle(item.name, next)}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
<span class="text-12-regular text-text-weak">
|
||||
{enabled() ? lang.t("settings.mcp.state.enabled") : lang.t("settings.mcp.state.disabled")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button size="large" variant="ghost" disabled={busy()} onClick={() => remove(item.name)}>
|
||||
{pending() ? spin() : lang.t("settings.mcp.action.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong">{lang.t("settings.mcp.section.add")}</h3>
|
||||
<p class="text-12-regular text-text-weak">{lang.t("settings.mcp.section.add.description")}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-border-weak-base bg-surface-raised-base p-4 sm:p-5">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-12-medium text-text-strong">{lang.t("settings.mcp.form.type.label")}</span>
|
||||
<div class="inline-flex w-full gap-1 rounded-xl bg-surface-base p-1 sm:w-auto">
|
||||
<For each={["remote", "local"] as const}>
|
||||
{(mode) => (
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 flex-1 rounded-lg px-3 text-12-medium transition-colors sm:flex-none"
|
||||
classList={{
|
||||
"bg-surface-raised-base text-text-strong": state.form.mode === mode,
|
||||
"text-text-weak": state.form.mode !== mode,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (busy()) return
|
||||
setState("form", "mode", mode)
|
||||
}}
|
||||
>
|
||||
{kind(mode)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<TextField
|
||||
label={lang.t("settings.mcp.form.name.label")}
|
||||
value={state.form.name}
|
||||
onChange={(value) => setState("form", "name", value)}
|
||||
placeholder={lang.t("settings.mcp.form.name.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={lang.t("settings.mcp.form.timeout.label")}
|
||||
value={state.form.timeout}
|
||||
onChange={(value) => setState("form", "timeout", value)}
|
||||
placeholder={lang.t("settings.mcp.form.timeout.placeholder")}
|
||||
inputMode="numeric"
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={state.form.mode === "remote"}
|
||||
fallback={
|
||||
<>
|
||||
<TextField
|
||||
label={lang.t("settings.mcp.form.command.label")}
|
||||
value={state.form.command}
|
||||
onChange={(value) => setState("form", "command", value)}
|
||||
placeholder={lang.t("settings.mcp.form.command.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={lang.t("settings.mcp.form.environment.label")}
|
||||
description={lang.t("settings.mcp.form.environment.description")}
|
||||
value={state.form.environment}
|
||||
onChange={(value) => setState("form", "environment", value)}
|
||||
placeholder="API_KEY={env:API_KEY}"
|
||||
multiline
|
||||
rows={4}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
label={lang.t("settings.mcp.form.url.label")}
|
||||
value={state.form.url}
|
||||
onChange={(value) => setState("form", "url", value)}
|
||||
placeholder={lang.t("settings.mcp.form.url.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={lang.t("settings.mcp.form.headers.label")}
|
||||
description={lang.t("settings.mcp.form.headers.description")}
|
||||
value={state.form.headers}
|
||||
onChange={(value) => setState("form", "headers", value)}
|
||||
placeholder="Authorization: Bearer {env:API_KEY}"
|
||||
multiline
|
||||
rows={4}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
class="w-full justify-center sm:w-auto"
|
||||
disabled={busy()}
|
||||
onClick={addForm}
|
||||
>
|
||||
{state.submitting === "form" ? spin() : lang.t("settings.mcp.action.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { isEditableTarget } from "@/utils/dom"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
@@ -178,6 +177,14 @@ export function formatKeybind(config: string): string {
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
if (target.closest("[contenteditable='true']")) return true
|
||||
if (target.closest("input, textarea, select")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||
name: "Command",
|
||||
init: () => {
|
||||
|
||||
@@ -353,6 +353,7 @@ function createGlobalSync() {
|
||||
.update({ config })
|
||||
.then(bootstrap)
|
||||
.then(() => {
|
||||
queue.refresh()
|
||||
setGlobalStore("reload", undefined)
|
||||
queue.refresh()
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@opencode-ai/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface Settings {
|
||||
autoSave: boolean
|
||||
releaseNotes: boolean
|
||||
showReasoningSummaries: boolean
|
||||
shellToolPartsExpanded: boolean
|
||||
editToolPartsExpanded: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
@@ -44,6 +46,8 @@ const defaultSettings: Settings = {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
showReasoningSummaries: false,
|
||||
shellToolPartsExpanded: true,
|
||||
editToolPartsExpanded: false,
|
||||
},
|
||||
updates: {
|
||||
startup: true,
|
||||
@@ -129,6 +133,20 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowReasoningSummaries(value: boolean) {
|
||||
setStore("general", "showReasoningSummaries", value)
|
||||
},
|
||||
shellToolPartsExpanded: withFallback(
|
||||
() => store.general?.shellToolPartsExpanded,
|
||||
defaultSettings.general.shellToolPartsExpanded,
|
||||
),
|
||||
setShellToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "shellToolPartsExpanded", value)
|
||||
},
|
||||
editToolPartsExpanded: withFallback(
|
||||
() => store.general?.editToolPartsExpanded,
|
||||
defaultSettings.general.editToolPartsExpanded,
|
||||
),
|
||||
setEditToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "editToolPartsExpanded", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||
|
||||
@@ -541,6 +541,12 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
||||
"settings.general.row.font.title": "الخط",
|
||||
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
|
||||
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
|
||||
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
|
||||
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -659,6 +665,47 @@ export const dict = {
|
||||
"settings.commands.description": "ستكون إعدادات الأمر قابلة للتكوين هنا.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "ستكون إعدادات MCP قابلة للتكوين هنا.",
|
||||
"settings.mcp.section.featured": "مميّز",
|
||||
"settings.mcp.section.featured.description": "أضف إعدادًا مسبقًا لخوادم MCP الشهيرة بنقرة واحدة.",
|
||||
"settings.mcp.section.configured": "الخوادم المهيأة",
|
||||
"settings.mcp.section.configured.description":
|
||||
"اطلع على خوادم MCP المثبتة، وكيفية اتصالها، وقم بإزالة تلك التي لم تعد بحاجة إليها.",
|
||||
"settings.mcp.section.add": "أضف خادمًا",
|
||||
"settings.mcp.section.add.description": "أنشئ تكوين خادم MCP محلي أو بعيد خاص بك.",
|
||||
"settings.mcp.type.local": "محلي",
|
||||
"settings.mcp.type.remote": "بعيد",
|
||||
"settings.mcp.featured.added": "تمت الإضافة",
|
||||
"settings.mcp.action.add": "أضف خادمًا",
|
||||
"settings.mcp.action.remove": "إزالة",
|
||||
"settings.mcp.state.enabled": "مفعل",
|
||||
"settings.mcp.state.disabled": "معطل",
|
||||
"settings.mcp.form.type.label": "نوع الاتصال",
|
||||
"settings.mcp.form.name.label": "اسم الخادم",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "عنوان URL البعيد",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "الأمر",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "الرؤوس (Headers)",
|
||||
"settings.mcp.form.headers.description": "اختياري. أضف رأسًا واحدًا في كل سطر بصيغة KEY: value.",
|
||||
"settings.mcp.form.environment.label": "البيئة",
|
||||
"settings.mcp.form.environment.description": "اختياري. أضف متغيرًا واحدًا في كل سطر بصيغة KEY=value.",
|
||||
"settings.mcp.form.timeout.label": "المهلة (مللي ثانية)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "تمت إضافة خادم MCP",
|
||||
"settings.mcp.toast.added.description": "تم حفظ {{name}} في إعدادات MCP الخاصة بك.",
|
||||
"settings.mcp.toast.updated.title": "تم تحديث خادم MCP",
|
||||
"settings.mcp.toast.updated.description": "أصبح {{name}} الآن {{state}}.",
|
||||
"settings.mcp.toast.removed.title": "تمت إزالة خادم MCP",
|
||||
"settings.mcp.toast.removed.description": "تمت إزالة {{name}} من إعدادات MCP الخاصة بك.",
|
||||
"settings.mcp.validation.name": "أدخل اسم الخادم قبل الحفظ.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} مهيأ بالفعل.",
|
||||
"settings.mcp.validation.url": "أدخل عنوان URL لخادم MCP البعيد.",
|
||||
"settings.mcp.validation.command": "أدخل الأمر المستخدم لبدء تشغيل خادم MCP المحلي.",
|
||||
"settings.mcp.validation.timeout": "يجب أن تكون المهلة عددًا صحيحًا موجبًا.",
|
||||
"settings.mcp.validation.headers": "تعذر تحليل سطر الرأس: {{line}}",
|
||||
"settings.mcp.validation.environment": "تعذر تحليل سطر البيئة: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "يحتاج إلى تسجيل العميل",
|
||||
"settings.permissions.title": "الأذونات",
|
||||
"settings.permissions.description": "تحكم في الأدوات التي يمكن للخادم استخدامها بشكل افتراضي.",
|
||||
"settings.permissions.section.tools": "الأدوات",
|
||||
|
||||
@@ -547,6 +547,12 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
|
||||
"settings.general.row.font.title": "Fonte",
|
||||
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
|
||||
"settings.general.row.wayland.title": "Usar Wayland nativo",
|
||||
"settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -667,6 +673,48 @@ export const dict = {
|
||||
"settings.commands.description": "Configurações de comandos estarão disponíveis aqui.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "Configurações de MCP estarão disponíveis aqui.",
|
||||
"settings.mcp.section.featured": "Destaques",
|
||||
"settings.mcp.section.featured.description":
|
||||
"Adicione uma predefinição refinada para servidores MCP populares com um clique.",
|
||||
"settings.mcp.section.configured": "Servidores configurados",
|
||||
"settings.mcp.section.configured.description":
|
||||
"Veja quais servidores MCP estão instalados, como eles se conectam e remova os que você não precisa mais.",
|
||||
"settings.mcp.section.add": "Adicionar servidor",
|
||||
"settings.mcp.section.add.description": "Crie sua própria configuração de servidor MCP local ou remoto.",
|
||||
"settings.mcp.type.local": "Local",
|
||||
"settings.mcp.type.remote": "Remoto",
|
||||
"settings.mcp.featured.added": "Adicionado",
|
||||
"settings.mcp.action.add": "Adicionar servidor",
|
||||
"settings.mcp.action.remove": "Remover",
|
||||
"settings.mcp.state.enabled": "Habilitado",
|
||||
"settings.mcp.state.disabled": "Desabilitado",
|
||||
"settings.mcp.form.type.label": "Tipo de conexão",
|
||||
"settings.mcp.form.name.label": "Nome do servidor",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "URL remota",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "Comando",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "Cabeçalhos",
|
||||
"settings.mcp.form.headers.description": "Opcional. Adicione um cabeçalho por linha usando KEY: value.",
|
||||
"settings.mcp.form.environment.label": "Ambiente",
|
||||
"settings.mcp.form.environment.description": "Opcional. Adicione uma variável por linha usando KEY=value.",
|
||||
"settings.mcp.form.timeout.label": "Tempo limite (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "Servidor MCP adicionado",
|
||||
"settings.mcp.toast.added.description": "{{name}} foi salvo nas suas configurações do MCP.",
|
||||
"settings.mcp.toast.updated.title": "Servidor MCP atualizado",
|
||||
"settings.mcp.toast.updated.description": "{{name}} está agora {{state}}.",
|
||||
"settings.mcp.toast.removed.title": "Servidor MCP removido",
|
||||
"settings.mcp.toast.removed.description": "{{name}} foi removido das suas configurações do MCP.",
|
||||
"settings.mcp.validation.name": "Insira um nome para o servidor antes de salvar.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} já está configurado.",
|
||||
"settings.mcp.validation.url": "Insira uma URL de servidor MCP remoto.",
|
||||
"settings.mcp.validation.command": "Insira o comando usado para iniciar o servidor MCP local.",
|
||||
"settings.mcp.validation.timeout": "O tempo limite deve ser um número inteiro positivo.",
|
||||
"settings.mcp.validation.headers": "Não foi possível analisar a linha do cabeçalho: {{line}}",
|
||||
"settings.mcp.validation.environment": "Não foi possível analisar a linha de ambiente: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "Requer registro do cliente",
|
||||
"settings.permissions.title": "Permissões",
|
||||
"settings.permissions.description": "Controle quais ferramentas o servidor pode usar por padrão.",
|
||||
"settings.permissions.section.tools": "Ferramentas",
|
||||
|
||||
@@ -613,6 +613,12 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Font",
|
||||
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Prikaži dijelove shell alata podrazumijevano proširene na vremenskoj traci",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
|
||||
"settings.general.row.wayland.title": "Koristi nativni Wayland",
|
||||
"settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -739,6 +745,47 @@ export const dict = {
|
||||
"settings.commands.description": "Postavke komandi će se ovdje moći podešavati.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "MCP postavke će se ovdje moći podešavati.",
|
||||
"settings.mcp.section.featured": "Izdvojeno",
|
||||
"settings.mcp.section.featured.description": "Dodajte gotovu postavku za popularne MCP servere jednim klikom.",
|
||||
"settings.mcp.section.configured": "Konfigurisani serveri",
|
||||
"settings.mcp.section.configured.description":
|
||||
"Pogledajte koji su MCP serveri instalirani, kako se povezuju i uklonite one koji vam više ne trebaju.",
|
||||
"settings.mcp.section.add": "Dodaj server",
|
||||
"settings.mcp.section.add.description": "Kreirajte vlastitu konfiguraciju lokalnog ili udaljenog MCP servera.",
|
||||
"settings.mcp.type.local": "Lokalno",
|
||||
"settings.mcp.type.remote": "Udaljeno",
|
||||
"settings.mcp.featured.added": "Dodano",
|
||||
"settings.mcp.action.add": "Dodaj server",
|
||||
"settings.mcp.action.remove": "Ukloni",
|
||||
"settings.mcp.state.enabled": "Omogućeno",
|
||||
"settings.mcp.state.disabled": "Onemogućeno",
|
||||
"settings.mcp.form.type.label": "Tip konekcije",
|
||||
"settings.mcp.form.name.label": "Naziv servera",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "Udaljeni URL",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "Komanda",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "Zaglavlja",
|
||||
"settings.mcp.form.headers.description": "Opcionalno. Dodajte jedno zaglavlje po liniji koristeći KEY: value.",
|
||||
"settings.mcp.form.environment.label": "Okruženje",
|
||||
"settings.mcp.form.environment.description": "Opcionalno. Dodajte jednu varijablu po liniji koristeći KEY=value.",
|
||||
"settings.mcp.form.timeout.label": "Istek vremena (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "MCP server dodan",
|
||||
"settings.mcp.toast.added.description": "{{name}} je sačuvan u vaše MCP postavke.",
|
||||
"settings.mcp.toast.updated.title": "MCP server ažuriran",
|
||||
"settings.mcp.toast.updated.description": "{{name}} je sada {{state}}.",
|
||||
"settings.mcp.toast.removed.title": "MCP server uklonjen",
|
||||
"settings.mcp.toast.removed.description": "{{name}} je uklonjen iz vaših MCP postavki.",
|
||||
"settings.mcp.validation.name": "Unesite naziv servera prije čuvanja.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} je već konfigurisan.",
|
||||
"settings.mcp.validation.url": "Unesite URL udaljenog MCP servera.",
|
||||
"settings.mcp.validation.command": "Unesite komandu koja se koristi za pokretanje lokalnog MCP servera.",
|
||||
"settings.mcp.validation.timeout": "Istek vremena mora biti pozitivan cijeli broj.",
|
||||
"settings.mcp.validation.headers": "Nije moguće parsirati liniju zaglavlja: {{line}}",
|
||||
"settings.mcp.validation.environment": "Nije moguće parsirati liniju okruženja: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "Potrebna registracija klijenta",
|
||||
|
||||
"settings.permissions.title": "Dozvole",
|
||||
"settings.permissions.description": "Kontroliši koje alate server smije koristiti po defaultu.",
|
||||
|
||||
@@ -608,6 +608,11 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Skrifttype",
|
||||
"settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
|
||||
"settings.general.row.wayland.title": "Brug native Wayland",
|
||||
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -734,6 +739,48 @@ export const dict = {
|
||||
"settings.commands.description": "Kommandoindstillinger vil kunne konfigureres her.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "MCP-indstillinger vil kunne konfigureres her.",
|
||||
"settings.mcp.section.featured": "Udvalgte",
|
||||
"settings.mcp.section.featured.description":
|
||||
"Tilføj en poleret forudindstilling for populære MCP-servere med ét klik.",
|
||||
"settings.mcp.section.configured": "Konfigurerede servere",
|
||||
"settings.mcp.section.configured.description":
|
||||
"Se hvilke MCP-servere der er installeret, hvordan de forbinder, og fjern dem du ikke længere har brug for.",
|
||||
"settings.mcp.section.add": "Tilføj en server",
|
||||
"settings.mcp.section.add.description": "Opret din egen lokale eller fjern MCP-serverkonfiguration.",
|
||||
"settings.mcp.type.local": "Lokal",
|
||||
"settings.mcp.type.remote": "Fjern",
|
||||
"settings.mcp.featured.added": "Tilføjet",
|
||||
"settings.mcp.action.add": "Tilføj server",
|
||||
"settings.mcp.action.remove": "Fjern",
|
||||
"settings.mcp.state.enabled": "Aktiveret",
|
||||
"settings.mcp.state.disabled": "Deaktiveret",
|
||||
"settings.mcp.form.type.label": "Forbindelsestype",
|
||||
"settings.mcp.form.name.label": "Servernavn",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "Fjern-URL",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "Kommando",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "Headere",
|
||||
"settings.mcp.form.headers.description": "Valgfrit. Tilføj én header pr. linje ved brug af KEY: value.",
|
||||
"settings.mcp.form.environment.label": "Miljø",
|
||||
"settings.mcp.form.environment.description": "Valgfrit. Tilføj én variabel pr. linje ved brug af KEY=value.",
|
||||
"settings.mcp.form.timeout.label": "Timeout (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "MCP-server tilføjet",
|
||||
"settings.mcp.toast.added.description": "{{name}} er blevet gemt i dine MCP-indstillinger.",
|
||||
"settings.mcp.toast.updated.title": "MCP-server opdateret",
|
||||
"settings.mcp.toast.updated.description": "{{name}} er nu {{state}}.",
|
||||
"settings.mcp.toast.removed.title": "MCP-server fjernet",
|
||||
"settings.mcp.toast.removed.description": "{{name}} er blevet fjernet fra dine MCP-indstillinger.",
|
||||
"settings.mcp.validation.name": "Indtast et servernavn før du gemmer.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} er allerede konfigureret.",
|
||||
"settings.mcp.validation.url": "Indtast en URL til en fjern MCP-server.",
|
||||
"settings.mcp.validation.command": "Indtast kommandoen, der bruges til at starte den lokale MCP-server.",
|
||||
"settings.mcp.validation.timeout": "Timeout skal være et positivt heltal.",
|
||||
"settings.mcp.validation.headers": "Kunne ikke fortolke header-linje: {{line}}",
|
||||
"settings.mcp.validation.environment": "Kunne ikke fortolke miljø-linje: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "Kræver klientregistrering",
|
||||
|
||||
"settings.permissions.title": "Tilladelser",
|
||||
"settings.permissions.description": "Styr hvilke værktøjer serveren kan bruge som standard.",
|
||||
|
||||
@@ -556,6 +556,12 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
|
||||
"settings.general.row.font.title": "Schriftart",
|
||||
"settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Shell-Tool-Abschnitte ausklappen",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Shell-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||
"settings.general.row.wayland.title": "Natives Wayland verwenden",
|
||||
"settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -676,6 +682,47 @@ export const dict = {
|
||||
"settings.commands.description": "Befehlseinstellungen können hier konfiguriert werden.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "MCP-Einstellungen können hier konfiguriert werden.",
|
||||
"settings.mcp.section.featured": "Vorgestellt",
|
||||
"settings.mcp.section.featured.description": "Fügen Sie mit einem Klick eine Vorlage für beliebte MCP-Server hinzu.",
|
||||
"settings.mcp.section.configured": "Konfigurierte Server",
|
||||
"settings.mcp.section.configured.description":
|
||||
"Sehen Sie, welche MCP-Server installiert sind, wie sie verbunden sind, und entfernen Sie nicht mehr benötigte.",
|
||||
"settings.mcp.section.add": "Server hinzufügen",
|
||||
"settings.mcp.section.add.description": "Erstellen Sie Ihre eigene lokale oder Remote-MCP-Server-Konfiguration.",
|
||||
"settings.mcp.type.local": "Lokal",
|
||||
"settings.mcp.type.remote": "Remote",
|
||||
"settings.mcp.featured.added": "Hinzugefügt",
|
||||
"settings.mcp.action.add": "Server hinzufügen",
|
||||
"settings.mcp.action.remove": "Entfernen",
|
||||
"settings.mcp.state.enabled": "Aktiviert",
|
||||
"settings.mcp.state.disabled": "Deaktiviert",
|
||||
"settings.mcp.form.type.label": "Verbindungstyp",
|
||||
"settings.mcp.form.name.label": "Servername",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "Remote-URL",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "Befehl",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "Header",
|
||||
"settings.mcp.form.headers.description": "Optional. Fügen Sie einen Header pro Zeile im Format KEY: value hinzu.",
|
||||
"settings.mcp.form.environment.label": "Umgebung",
|
||||
"settings.mcp.form.environment.description": "Optional. Fügen Sie eine Variable pro Zeile im Format KEY=value hinzu.",
|
||||
"settings.mcp.form.timeout.label": "Timeout (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "MCP-Server hinzugefügt",
|
||||
"settings.mcp.toast.added.description": "{{name}} wurde in Ihren MCP-Einstellungen gespeichert.",
|
||||
"settings.mcp.toast.updated.title": "MCP-Server aktualisiert",
|
||||
"settings.mcp.toast.updated.description": "{{name}} ist jetzt {{state}}.",
|
||||
"settings.mcp.toast.removed.title": "MCP-Server entfernt",
|
||||
"settings.mcp.toast.removed.description": "{{name}} wurde aus Ihren MCP-Einstellungen entfernt.",
|
||||
"settings.mcp.validation.name": "Geben Sie vor dem Speichern einen Servernamen ein.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} ist bereits konfiguriert.",
|
||||
"settings.mcp.validation.url": "Geben Sie eine Remote-MCP-Server-URL ein.",
|
||||
"settings.mcp.validation.command": "Geben Sie den Befehl zum Starten des lokalen MCP-Servers ein.",
|
||||
"settings.mcp.validation.timeout": "Das Timeout muss eine positive ganze Zahl sein.",
|
||||
"settings.mcp.validation.headers": "Header-Zeile konnte nicht verarbeitet werden: {{line}}",
|
||||
"settings.mcp.validation.environment": "Umgebungsvariablen-Zeile konnte nicht verarbeitet werden: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "Client-Registrierung erforderlich",
|
||||
"settings.permissions.title": "Berechtigungen",
|
||||
"settings.permissions.description": "Steuern Sie, welche Tools der Server standardmäßig verwenden darf.",
|
||||
"settings.permissions.section.tools": "Tools",
|
||||
|
||||
@@ -636,6 +636,13 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Customise the mono font used in code blocks",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Show shell tool parts expanded by default in the timeline",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Show edit, write, and patch tool parts expanded by default in the timeline",
|
||||
|
||||
"settings.general.row.wayland.title": "Use native Wayland",
|
||||
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -763,7 +770,48 @@ export const dict = {
|
||||
"settings.commands.title": "Commands",
|
||||
"settings.commands.description": "Command settings will be configurable here.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "MCP settings will be configurable here.",
|
||||
"settings.mcp.description": "Manage local and remote MCP servers that OpenCode can use across your workspaces.",
|
||||
"settings.mcp.section.featured": "Featured",
|
||||
"settings.mcp.section.featured.description": "Add a polished preset for popular MCP servers in one click.",
|
||||
"settings.mcp.section.configured": "Configured servers",
|
||||
"settings.mcp.section.configured.description":
|
||||
"See which MCP servers are installed, how they connect, and remove the ones you no longer need.",
|
||||
"settings.mcp.section.add": "Add a server",
|
||||
"settings.mcp.section.add.description": "Create your own local or remote MCP server configuration.",
|
||||
"settings.mcp.type.local": "Local",
|
||||
"settings.mcp.type.remote": "Remote",
|
||||
"settings.mcp.featured.added": "Added",
|
||||
"settings.mcp.action.add": "Add server",
|
||||
"settings.mcp.action.remove": "Remove",
|
||||
"settings.mcp.state.enabled": "Enabled",
|
||||
"settings.mcp.state.disabled": "Disabled",
|
||||
"settings.mcp.form.type.label": "Connection type",
|
||||
"settings.mcp.form.name.label": "Server name",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "Remote URL",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "Command",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "Headers",
|
||||
"settings.mcp.form.headers.description": "Optional. Add one header per line using KEY: value.",
|
||||
"settings.mcp.form.environment.label": "Environment",
|
||||
"settings.mcp.form.environment.description": "Optional. Add one variable per line using KEY=value.",
|
||||
"settings.mcp.form.timeout.label": "Timeout (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "MCP server added",
|
||||
"settings.mcp.toast.added.description": "{{name}} has been saved to your MCP settings.",
|
||||
"settings.mcp.toast.updated.title": "MCP server updated",
|
||||
"settings.mcp.toast.updated.description": "{{name}} is now {{state}}.",
|
||||
"settings.mcp.toast.removed.title": "MCP server removed",
|
||||
"settings.mcp.toast.removed.description": "{{name}} has been removed from your MCP settings.",
|
||||
"settings.mcp.validation.name": "Enter a server name before saving.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} is already configured.",
|
||||
"settings.mcp.validation.url": "Enter a remote MCP server URL.",
|
||||
"settings.mcp.validation.command": "Enter the command used to start the local MCP server.",
|
||||
"settings.mcp.validation.timeout": "Timeout must be a positive whole number.",
|
||||
"settings.mcp.validation.headers": "Couldn't parse header line: {{line}}",
|
||||
"settings.mcp.validation.environment": "Couldn't parse environment line: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "Needs client registration",
|
||||
|
||||
"settings.permissions.title": "Permissions",
|
||||
"settings.permissions.description": "Control what tools the server can use by default.",
|
||||
|
||||
@@ -616,6 +616,12 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Fuente",
|
||||
"settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes de la herramienta shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Mostrar las partes de la herramienta shell expandidas por defecto en la línea de tiempo",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
|
||||
"settings.general.row.wayland.title": "Usar Wayland nativo",
|
||||
"settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -745,6 +751,48 @@ export const dict = {
|
||||
"settings.commands.description": "La configuración de comandos estará disponible aquí.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "La configuración de MCP estará disponible aquí.",
|
||||
"settings.mcp.section.featured": "Destacados",
|
||||
"settings.mcp.section.featured.description":
|
||||
"Añade un ajuste preestablecido para servidores MCP populares con un solo clic.",
|
||||
"settings.mcp.section.configured": "Servidores configurados",
|
||||
"settings.mcp.section.configured.description":
|
||||
"Consulta qué servidores MCP están instalados, cómo se conectan y elimina los que ya no necesites.",
|
||||
"settings.mcp.section.add": "Añadir un servidor",
|
||||
"settings.mcp.section.add.description": "Crea tu propia configuración de servidor MCP local o remoto.",
|
||||
"settings.mcp.type.local": "Local",
|
||||
"settings.mcp.type.remote": "Remoto",
|
||||
"settings.mcp.featured.added": "Añadido",
|
||||
"settings.mcp.action.add": "Añadir servidor",
|
||||
"settings.mcp.action.remove": "Eliminar",
|
||||
"settings.mcp.state.enabled": "Habilitado",
|
||||
"settings.mcp.state.disabled": "Deshabilitado",
|
||||
"settings.mcp.form.type.label": "Tipo de conexión",
|
||||
"settings.mcp.form.name.label": "Nombre del servidor",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "URL remota",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "Comando",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "Encabezados",
|
||||
"settings.mcp.form.headers.description": "Opcional. Añade un encabezado por línea usando KEY: value.",
|
||||
"settings.mcp.form.environment.label": "Entorno",
|
||||
"settings.mcp.form.environment.description": "Opcional. Añade una variable por línea usando KEY=value.",
|
||||
"settings.mcp.form.timeout.label": "Tiempo de espera (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "Servidor MCP añadido",
|
||||
"settings.mcp.toast.added.description": "{{name}} se ha guardado en tu configuración de MCP.",
|
||||
"settings.mcp.toast.updated.title": "Servidor MCP actualizado",
|
||||
"settings.mcp.toast.updated.description": "{{name}} está ahora {{state}}.",
|
||||
"settings.mcp.toast.removed.title": "Servidor MCP eliminado",
|
||||
"settings.mcp.toast.removed.description": "{{name}} se ha eliminado de tu configuración de MCP.",
|
||||
"settings.mcp.validation.name": "Introduce un nombre de servidor antes de guardar.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} ya está configurado.",
|
||||
"settings.mcp.validation.url": "Introduce una URL de servidor MCP remoto.",
|
||||
"settings.mcp.validation.command": "Introduce el comando utilizado para iniciar el servidor MCP local.",
|
||||
"settings.mcp.validation.timeout": "El tiempo de espera debe ser un número entero positivo.",
|
||||
"settings.mcp.validation.headers": "No se pudo analizar la línea de encabezado: {{line}}",
|
||||
"settings.mcp.validation.environment": "No se pudo analizar la línea de entorno: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "Requiere registro de cliente",
|
||||
|
||||
"settings.permissions.title": "Permisos",
|
||||
"settings.permissions.description": "Controla qué herramientas puede usar el servidor por defecto.",
|
||||
|
||||
@@ -553,6 +553,12 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
|
||||
"settings.general.row.font.title": "Police",
|
||||
"settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Développer les parties de l'outil shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Afficher les parties de l'outil shell développées par défaut dans la chronologie",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
|
||||
"settings.general.row.wayland.title": "Utiliser Wayland natif",
|
||||
"settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -673,6 +679,48 @@ export const dict = {
|
||||
"settings.commands.description": "Les paramètres des commandes seront configurables ici.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "Les paramètres MCP seront configurables ici.",
|
||||
"settings.mcp.section.featured": "En vedette",
|
||||
"settings.mcp.section.featured.description":
|
||||
"Ajoutez un préréglage soigné pour les serveurs MCP populaires en un clic.",
|
||||
"settings.mcp.section.configured": "Serveurs configurés",
|
||||
"settings.mcp.section.configured.description":
|
||||
"Consultez les serveurs MCP installés, leur mode de connexion, et supprimez ceux dont vous n'avez plus besoin.",
|
||||
"settings.mcp.section.add": "Ajouter un serveur",
|
||||
"settings.mcp.section.add.description": "Créez votre propre configuration de serveur MCP local ou distant.",
|
||||
"settings.mcp.type.local": "Local",
|
||||
"settings.mcp.type.remote": "Distant",
|
||||
"settings.mcp.featured.added": "Ajouté",
|
||||
"settings.mcp.action.add": "Ajouter un serveur",
|
||||
"settings.mcp.action.remove": "Supprimer",
|
||||
"settings.mcp.state.enabled": "Activé",
|
||||
"settings.mcp.state.disabled": "Désactivé",
|
||||
"settings.mcp.form.type.label": "Type de connexion",
|
||||
"settings.mcp.form.name.label": "Nom du serveur",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "URL distante",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "Commande",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "En-têtes",
|
||||
"settings.mcp.form.headers.description": "Facultatif. Ajoutez un en-tête par ligne au format KEY: value.",
|
||||
"settings.mcp.form.environment.label": "Environnement",
|
||||
"settings.mcp.form.environment.description": "Facultatif. Ajoutez une variable par ligne au format KEY=value.",
|
||||
"settings.mcp.form.timeout.label": "Délai d'attente (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "Serveur MCP ajouté",
|
||||
"settings.mcp.toast.added.description": "{{name}} a été enregistré dans vos paramètres MCP.",
|
||||
"settings.mcp.toast.updated.title": "Serveur MCP mis à jour",
|
||||
"settings.mcp.toast.updated.description": "{{name}} est désormais {{state}}.",
|
||||
"settings.mcp.toast.removed.title": "Serveur MCP supprimé",
|
||||
"settings.mcp.toast.removed.description": "{{name}} a été supprimé de vos paramètres MCP.",
|
||||
"settings.mcp.validation.name": "Saisissez un nom de serveur avant d'enregistrer.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} est déjà configuré.",
|
||||
"settings.mcp.validation.url": "Saisissez une URL de serveur MCP distant.",
|
||||
"settings.mcp.validation.command": "Saisissez la commande utilisée pour démarrer le serveur MCP local.",
|
||||
"settings.mcp.validation.timeout": "Le délai d'attente doit être un nombre entier positif.",
|
||||
"settings.mcp.validation.headers": "Impossible d'analyser la ligne d'en-tête : {{line}}",
|
||||
"settings.mcp.validation.environment": "Impossible d'analyser la ligne d'environnement : {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "Nécessite l'enregistrement du client",
|
||||
"settings.permissions.title": "Permissions",
|
||||
"settings.permissions.description": "Contrôlez les outils que le serveur peut utiliser par défaut.",
|
||||
"settings.permissions.section.tools": "Outils",
|
||||
|
||||
@@ -545,6 +545,12 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
|
||||
"settings.general.row.font.title": "フォント",
|
||||
"settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"タイムラインで shell ツールパーツをデフォルトで展開して表示します",
|
||||
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
|
||||
"settings.general.row.wayland.title": "ネイティブWaylandを使用",
|
||||
"settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -664,6 +670,48 @@ export const dict = {
|
||||
"settings.commands.description": "コマンド設定はここで構成できます。",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "MCP設定はここで構成できます。",
|
||||
"settings.mcp.section.featured": "おすすめ",
|
||||
"settings.mcp.section.featured.description":
|
||||
"人気のMCPサーバー向けに調整されたプリセットをワンクリックで追加できます。",
|
||||
"settings.mcp.section.configured": "設定済みのサーバー",
|
||||
"settings.mcp.section.configured.description":
|
||||
"インストールされているMCPサーバーや接続方法を確認したり、不要になったサーバーを削除したりできます。",
|
||||
"settings.mcp.section.add": "サーバーを追加",
|
||||
"settings.mcp.section.add.description": "ローカルまたはリモートの独自のMCPサーバー設定を作成します。",
|
||||
"settings.mcp.type.local": "ローカル",
|
||||
"settings.mcp.type.remote": "リモート",
|
||||
"settings.mcp.featured.added": "追加済み",
|
||||
"settings.mcp.action.add": "サーバーを追加",
|
||||
"settings.mcp.action.remove": "削除",
|
||||
"settings.mcp.state.enabled": "有効",
|
||||
"settings.mcp.state.disabled": "無効",
|
||||
"settings.mcp.form.type.label": "接続タイプ",
|
||||
"settings.mcp.form.name.label": "サーバー名",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "リモートURL",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "コマンド",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "ヘッダー",
|
||||
"settings.mcp.form.headers.description": "任意。1行につき1つのヘッダーを KEY: value の形式で追加してください。",
|
||||
"settings.mcp.form.environment.label": "環境変数",
|
||||
"settings.mcp.form.environment.description": "任意。1行につき1つの変数を KEY=value の形式で追加してください。",
|
||||
"settings.mcp.form.timeout.label": "タイムアウト (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "MCPサーバーを追加しました",
|
||||
"settings.mcp.toast.added.description": "{{name}} をMCP設定に保存しました。",
|
||||
"settings.mcp.toast.updated.title": "MCPサーバーを更新しました",
|
||||
"settings.mcp.toast.updated.description": "{{name}} は現在{{state}}です。",
|
||||
"settings.mcp.toast.removed.title": "MCPサーバーを削除しました",
|
||||
"settings.mcp.toast.removed.description": "MCP設定から {{name}} を削除しました。",
|
||||
"settings.mcp.validation.name": "保存する前にサーバー名を入力してください。",
|
||||
"settings.mcp.validation.duplicate": "{{name}} は既に設定されています。",
|
||||
"settings.mcp.validation.url": "リモートMCPサーバーのURLを入力してください。",
|
||||
"settings.mcp.validation.command": "ローカルMCPサーバーの起動コマンドを入力してください。",
|
||||
"settings.mcp.validation.timeout": "タイムアウトには正の整数を指定してください。",
|
||||
"settings.mcp.validation.headers": "ヘッダー行を解析できませんでした: {{line}}",
|
||||
"settings.mcp.validation.environment": "環境変数の行を解析できませんでした: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "クライアント登録が必要です",
|
||||
"settings.permissions.title": "権限",
|
||||
"settings.permissions.description": "サーバーがデフォルトで使用できるツールを制御します。",
|
||||
"settings.permissions.section.tools": "ツール",
|
||||
|
||||
@@ -546,6 +546,12 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
|
||||
"settings.general.row.font.title": "글꼴",
|
||||
"settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "shell 도구 파트 펼치기",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"타임라인에서 기본적으로 shell 도구 파트를 펼친 상태로 표시합니다",
|
||||
"settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다",
|
||||
"settings.general.row.wayland.title": "네이티브 Wayland 사용",
|
||||
"settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -664,6 +670,49 @@ export const dict = {
|
||||
"settings.commands.description": "명령어 설정은 여기서 구성할 수 있습니다.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "MCP 설정은 여기서 구성할 수 있습니다.",
|
||||
"settings.mcp.section.featured": "추천",
|
||||
"settings.mcp.section.featured.description": "클릭 한 번으로 인기 있는 MCP 서버에 대한 프리셋을 추가하세요.",
|
||||
"settings.mcp.section.configured": "구성된 서버",
|
||||
"settings.mcp.section.configured.description":
|
||||
"설치된 MCP 서버와 연결 방식을 확인하고, 더 이상 필요하지 않은 서버를 제거하세요.",
|
||||
"settings.mcp.section.add": "서버 추가",
|
||||
"settings.mcp.section.add.description": "로컬 또는 원격 MCP 서버 구성을 직접 생성하세요.",
|
||||
"settings.mcp.type.local": "로컬",
|
||||
"settings.mcp.type.remote": "원격",
|
||||
"settings.mcp.featured.added": "추가됨",
|
||||
"settings.mcp.action.add": "서버 추가",
|
||||
"settings.mcp.action.remove": "제거",
|
||||
"settings.mcp.state.enabled": "활성화됨",
|
||||
"settings.mcp.state.disabled": "비활성화됨",
|
||||
"settings.mcp.form.type.label": "연결 유형",
|
||||
"settings.mcp.form.name.label": "서버 이름",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "원격 URL",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "명령",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "헤더",
|
||||
"settings.mcp.form.headers.description":
|
||||
"선택 사항입니다. KEY: value 형식을 사용하여 한 줄에 하나의 헤더를 추가하세요.",
|
||||
"settings.mcp.form.environment.label": "환경 변수",
|
||||
"settings.mcp.form.environment.description":
|
||||
"선택 사항입니다. KEY=value 형식을 사용하여 한 줄에 하나의 변수를 추가하세요.",
|
||||
"settings.mcp.form.timeout.label": "시간 초과 (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "MCP 서버 추가됨",
|
||||
"settings.mcp.toast.added.description": "{{name}} 서버가 MCP 설정에 저장되었습니다.",
|
||||
"settings.mcp.toast.updated.title": "MCP 서버 업데이트됨",
|
||||
"settings.mcp.toast.updated.description": "{{name}}이(가) 이제 {{state}} 상태입니다.",
|
||||
"settings.mcp.toast.removed.title": "MCP 서버 제거됨",
|
||||
"settings.mcp.toast.removed.description": "{{name}} 서버가 MCP 설정에서 제거되었습니다.",
|
||||
"settings.mcp.validation.name": "저장하기 전에 서버 이름을 입력하세요.",
|
||||
"settings.mcp.validation.duplicate": "{{name}}은(는) 이미 구성되어 있습니다.",
|
||||
"settings.mcp.validation.url": "원격 MCP 서버 URL을 입력하세요.",
|
||||
"settings.mcp.validation.command": "로컬 MCP 서버를 시작하는 데 사용되는 명령을 입력하세요.",
|
||||
"settings.mcp.validation.timeout": "시간 초과는 양의 정수여야 합니다.",
|
||||
"settings.mcp.validation.headers": "헤더 줄을 구문 분석할 수 없습니다: {{line}}",
|
||||
"settings.mcp.validation.environment": "환경 변수 줄을 구문 분석할 수 없습니다: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "클라이언트 등록 필요",
|
||||
"settings.permissions.title": "권한",
|
||||
"settings.permissions.description": "서버가 기본적으로 사용할 수 있는 도구를 제어합니다.",
|
||||
"settings.permissions.section.tools": "도구",
|
||||
|
||||
@@ -616,6 +616,11 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Skrift",
|
||||
"settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Utvid shell-verktøydeler",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-verktøydeler utvidet som standard i tidslinjen",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen",
|
||||
"settings.general.row.wayland.title": "Bruk innebygd Wayland",
|
||||
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -742,6 +747,47 @@ export const dict = {
|
||||
"settings.commands.description": "Kommandoinnstillinger vil kunne konfigureres her.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "MCP-innstillinger vil kunne konfigureres her.",
|
||||
"settings.mcp.section.featured": "Utvalgte",
|
||||
"settings.mcp.section.featured.description": "Legg til et ferdig oppsett for populære MCP-servere med ett klikk.",
|
||||
"settings.mcp.section.configured": "Konfigurerte servere",
|
||||
"settings.mcp.section.configured.description":
|
||||
"Se hvilke MCP-servere som er installert, hvordan de kobler til, og fjern de du ikke lenger trenger.",
|
||||
"settings.mcp.section.add": "Legg til en server",
|
||||
"settings.mcp.section.add.description": "Opprett din egen konfigurasjon for lokal eller ekstern MCP-server.",
|
||||
"settings.mcp.type.local": "Lokal",
|
||||
"settings.mcp.type.remote": "Ekstern",
|
||||
"settings.mcp.featured.added": "Lagt til",
|
||||
"settings.mcp.action.add": "Legg til server",
|
||||
"settings.mcp.action.remove": "Fjern",
|
||||
"settings.mcp.state.enabled": "Aktivert",
|
||||
"settings.mcp.state.disabled": "Deaktivert",
|
||||
"settings.mcp.form.type.label": "Tilkoblingstype",
|
||||
"settings.mcp.form.name.label": "Servernavn",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "Ekstern URL",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "Kommando",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "Headere",
|
||||
"settings.mcp.form.headers.description": "Valgfritt. Legg til én header per linje med KEY: value.",
|
||||
"settings.mcp.form.environment.label": "Miljø",
|
||||
"settings.mcp.form.environment.description": "Valgfritt. Legg til én variabel per linje med KEY=value.",
|
||||
"settings.mcp.form.timeout.label": "Tidsavbrudd (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "MCP-server lagt til",
|
||||
"settings.mcp.toast.added.description": "{{name}} er lagret i dine MCP-innstillinger.",
|
||||
"settings.mcp.toast.updated.title": "MCP-server oppdatert",
|
||||
"settings.mcp.toast.updated.description": "{{name}} er nå {{state}}.",
|
||||
"settings.mcp.toast.removed.title": "MCP-server fjernet",
|
||||
"settings.mcp.toast.removed.description": "{{name}} er fjernet fra dine MCP-innstillinger.",
|
||||
"settings.mcp.validation.name": "Skriv inn et servernavn før du lagrer.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} er allerede konfigurert.",
|
||||
"settings.mcp.validation.url": "Skriv inn URL for ekstern MCP-server.",
|
||||
"settings.mcp.validation.command": "Skriv inn kommandoen for å starte den lokale MCP-serveren.",
|
||||
"settings.mcp.validation.timeout": "Tidsavbrudd må være et positivt heltall.",
|
||||
"settings.mcp.validation.headers": "Kunne ikke tolke header-linje: {{line}}",
|
||||
"settings.mcp.validation.environment": "Kunne ikke tolke miljøvariabel-linje: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "Krever klientregistrering",
|
||||
|
||||
"settings.permissions.title": "Tillatelser",
|
||||
"settings.permissions.description": "Kontroller hvilke verktøy serveren kan bruke som standard.",
|
||||
|
||||
@@ -546,6 +546,12 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
|
||||
"settings.general.row.font.title": "Czcionka",
|
||||
"settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Domyślnie pokazuj rozwinięte elementy narzędzia shell na osi czasu",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
|
||||
"settings.general.row.wayland.title": "Użyj natywnego Wayland",
|
||||
"settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -666,6 +672,48 @@ export const dict = {
|
||||
"settings.commands.description": "Ustawienia poleceń będą tutaj konfigurowalne.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "Ustawienia MCP będą tutaj konfigurowalne.",
|
||||
"settings.mcp.section.featured": "Wyróżnione",
|
||||
"settings.mcp.section.featured.description":
|
||||
"Dodaj dopracowany zestaw dla popularnych serwerów MCP jednym kliknięciem.",
|
||||
"settings.mcp.section.configured": "Skonfigurowane serwery",
|
||||
"settings.mcp.section.configured.description":
|
||||
"Zobacz, które serwery MCP są zainstalowane, jak się łączą, i usuń te, których już nie potrzebujesz.",
|
||||
"settings.mcp.section.add": "Dodaj serwer",
|
||||
"settings.mcp.section.add.description": "Utwórz własną konfigurację lokalnego lub zdalnego serwera MCP.",
|
||||
"settings.mcp.type.local": "Lokalny",
|
||||
"settings.mcp.type.remote": "Zdalny",
|
||||
"settings.mcp.featured.added": "Dodano",
|
||||
"settings.mcp.action.add": "Dodaj serwer",
|
||||
"settings.mcp.action.remove": "Usuń",
|
||||
"settings.mcp.state.enabled": "Włączony",
|
||||
"settings.mcp.state.disabled": "Wyłączony",
|
||||
"settings.mcp.form.type.label": "Typ połączenia",
|
||||
"settings.mcp.form.name.label": "Nazwa serwera",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "Zdalny adres URL",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "Polecenie",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "Nagłówki",
|
||||
"settings.mcp.form.headers.description": "Opcjonalne. Dodaj jeden nagłówek w wierszu używając formatu KEY: value.",
|
||||
"settings.mcp.form.environment.label": "Środowisko",
|
||||
"settings.mcp.form.environment.description": "Opcjonalne. Dodaj jedną zmienną w wierszu używając formatu KEY=value.",
|
||||
"settings.mcp.form.timeout.label": "Limit czasu (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "Dodano serwer MCP",
|
||||
"settings.mcp.toast.added.description": "{{name}} został zapisany w ustawieniach MCP.",
|
||||
"settings.mcp.toast.updated.title": "Zaktualizowano serwer MCP",
|
||||
"settings.mcp.toast.updated.description": "{{name}} jest teraz {{state}}.",
|
||||
"settings.mcp.toast.removed.title": "Usunięto serwer MCP",
|
||||
"settings.mcp.toast.removed.description": "{{name}} został usunięty z ustawień MCP.",
|
||||
"settings.mcp.validation.name": "Wprowadź nazwę serwera przed zapisaniem.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} jest już skonfigurowany.",
|
||||
"settings.mcp.validation.url": "Wprowadź adres URL zdalnego serwera MCP.",
|
||||
"settings.mcp.validation.command": "Wprowadź polecenie służące do uruchomienia lokalnego serwera MCP.",
|
||||
"settings.mcp.validation.timeout": "Limit czasu musi być dodatnią liczbą całkowitą.",
|
||||
"settings.mcp.validation.headers": "Nie udało się przetworzyć wiersza nagłówka: {{line}}",
|
||||
"settings.mcp.validation.environment": "Nie udało się przetworzyć wiersza środowiska: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "Wymaga rejestracji klienta",
|
||||
"settings.permissions.title": "Uprawnienia",
|
||||
"settings.permissions.description": "Kontroluj, jakich narzędzi serwer może używać domyślnie.",
|
||||
"settings.permissions.section.tools": "Narzędzia",
|
||||
|
||||
@@ -614,6 +614,12 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Шрифт",
|
||||
"settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Разворачивать элементы инструмента shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Показывать элементы инструмента shell в ленте развернутыми по умолчанию",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
|
||||
"settings.general.row.wayland.title": "Использовать нативный Wayland",
|
||||
"settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
@@ -741,6 +747,49 @@ export const dict = {
|
||||
"settings.commands.description": "Настройки команд будут доступны здесь.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "Настройки MCP будут доступны здесь.",
|
||||
"settings.mcp.section.featured": "Рекомендуемые",
|
||||
"settings.mcp.section.featured.description": "Добавляйте готовые настройки для популярных серверов MCP в один клик.",
|
||||
"settings.mcp.section.configured": "Настроенные серверы",
|
||||
"settings.mcp.section.configured.description":
|
||||
"Просматривайте установленные серверы MCP, способы их подключения и удаляйте ненужные.",
|
||||
"settings.mcp.section.add": "Добавить сервер",
|
||||
"settings.mcp.section.add.description": "Создайте собственную конфигурацию локального или удаленного сервера MCP.",
|
||||
"settings.mcp.type.local": "Локальный",
|
||||
"settings.mcp.type.remote": "Удаленный",
|
||||
"settings.mcp.featured.added": "Добавлено",
|
||||
"settings.mcp.action.add": "Добавить сервер",
|
||||
"settings.mcp.action.remove": "Удалить",
|
||||
"settings.mcp.state.enabled": "Включено",
|
||||
"settings.mcp.state.disabled": "Отключено",
|
||||
"settings.mcp.form.type.label": "Тип подключения",
|
||||
"settings.mcp.form.name.label": "Имя сервера",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "URL удаленного сервера",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "Команда",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "Заголовки",
|
||||
"settings.mcp.form.headers.description":
|
||||
"Необязательно. Добавьте по одному заголовку на строку в формате KEY: value.",
|
||||
"settings.mcp.form.environment.label": "Окружение",
|
||||
"settings.mcp.form.environment.description":
|
||||
"Необязательно. Добавьте по одной переменной на строку в формате KEY=value.",
|
||||
"settings.mcp.form.timeout.label": "Тайм-аут (мс)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "Сервер MCP добавлен",
|
||||
"settings.mcp.toast.added.description": "{{name}} сохранен в настройках MCP.",
|
||||
"settings.mcp.toast.updated.title": "Сервер MCP обновлен",
|
||||
"settings.mcp.toast.updated.description": "{{name}} теперь {{state}}.",
|
||||
"settings.mcp.toast.removed.title": "Сервер MCP удален",
|
||||
"settings.mcp.toast.removed.description": "{{name}} удален из настроек MCP.",
|
||||
"settings.mcp.validation.name": "Введите имя сервера перед сохранением.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} уже настроен.",
|
||||
"settings.mcp.validation.url": "Введите URL удаленного сервера MCP.",
|
||||
"settings.mcp.validation.command": "Введите команду для запуска локального сервера MCP.",
|
||||
"settings.mcp.validation.timeout": "Тайм-аут должен быть положительным целым числом.",
|
||||
"settings.mcp.validation.headers": "Не удалось разобрать строку заголовка: {{line}}",
|
||||
"settings.mcp.validation.environment": "Не удалось разобрать строку окружения: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "Требуется регистрация клиента",
|
||||
|
||||
"settings.permissions.title": "Разрешения",
|
||||
"settings.permissions.description": "Контролируйте какие инструменты сервер может использовать по умолчанию.",
|
||||
|
||||
@@ -608,6 +608,11 @@ export const dict = {
|
||||
"settings.general.row.font.title": "ฟอนต์",
|
||||
"settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "แสดงส่วนเครื่องมือ shell แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
|
||||
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
|
||||
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
|
||||
"settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท",
|
||||
"settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า",
|
||||
@@ -733,6 +738,47 @@ export const dict = {
|
||||
"settings.commands.description": "การตั้งค่าคำสั่งจะสามารถกำหนดค่าได้ที่นี่",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "การตั้งค่า MCP จะสามารถกำหนดค่าได้ที่นี่",
|
||||
"settings.mcp.section.featured": "แนะนำ",
|
||||
"settings.mcp.section.featured.description": "เพิ่มค่าที่ตั้งไว้ล่วงหน้าสำหรับเซิร์ฟเวอร์ MCP ยอดนิยมได้ในคลิกเดียว",
|
||||
"settings.mcp.section.configured": "เซิร์ฟเวอร์ที่กำหนดค่าแล้ว",
|
||||
"settings.mcp.section.configured.description":
|
||||
"ดูว่าเซิร์ฟเวอร์ MCP ใดติดตั้งอยู่ เชื่อมต่ออย่างไร และลบสิ่งที่ไม่ต้องการออก",
|
||||
"settings.mcp.section.add": "เพิ่มเซิร์ฟเวอร์",
|
||||
"settings.mcp.section.add.description": "สร้างการกำหนดค่าเซิร์ฟเวอร์ MCP แบบ Local หรือ Remote ของคุณเอง",
|
||||
"settings.mcp.type.local": "Local",
|
||||
"settings.mcp.type.remote": "Remote",
|
||||
"settings.mcp.featured.added": "เพิ่มแล้ว",
|
||||
"settings.mcp.action.add": "เพิ่มเซิร์ฟเวอร์",
|
||||
"settings.mcp.action.remove": "ลบ",
|
||||
"settings.mcp.state.enabled": "เปิดใช้งาน",
|
||||
"settings.mcp.state.disabled": "ปิดใช้งาน",
|
||||
"settings.mcp.form.type.label": "ประเภทการเชื่อมต่อ",
|
||||
"settings.mcp.form.name.label": "ชื่อเซิร์ฟเวอร์",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "URL ระยะไกล",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "คำสั่ง",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "Headers",
|
||||
"settings.mcp.form.headers.description": "ไม่บังคับ เพิ่มหนึ่ง header ต่อบรรทัดโดยใช้ KEY: value",
|
||||
"settings.mcp.form.environment.label": "Environment",
|
||||
"settings.mcp.form.environment.description": "ไม่บังคับ เพิ่มหนึ่งตัวแปรต่อบรรทัดโดยใช้ KEY=value",
|
||||
"settings.mcp.form.timeout.label": "Timeout (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "เพิ่มเซิร์ฟเวอร์ MCP แล้ว",
|
||||
"settings.mcp.toast.added.description": "บันทึก {{name}} ลงในค่าติดตั้ง MCP ของคุณแล้ว",
|
||||
"settings.mcp.toast.updated.title": "อัปเดตเซิร์ฟเวอร์ MCP แล้ว",
|
||||
"settings.mcp.toast.updated.description": "ขณะนี้ {{name}} อยู่ในสถานะ {{state}}",
|
||||
"settings.mcp.toast.removed.title": "ลบเซิร์ฟเวอร์ MCP แล้ว",
|
||||
"settings.mcp.toast.removed.description": "ลบ {{name}} ออกจากค่าติดตั้ง MCP ของคุณแล้ว",
|
||||
"settings.mcp.validation.name": "ป้อนชื่อเซิร์ฟเวอร์ก่อนบันทึก",
|
||||
"settings.mcp.validation.duplicate": "มีการกำหนดค่า {{name}} ไว้แล้ว",
|
||||
"settings.mcp.validation.url": "ป้อน URL ของเซิร์ฟเวอร์ MCP ระยะไกล",
|
||||
"settings.mcp.validation.command": "ป้อนคำสั่งที่ใช้เริ่มต้นเซิร์ฟเวอร์ MCP แบบ Local",
|
||||
"settings.mcp.validation.timeout": "Timeout ต้องเป็นจำนวนเต็มบวก",
|
||||
"settings.mcp.validation.headers": "ไม่สามารถแยกวิเคราะห์บรรทัด header: {{line}}",
|
||||
"settings.mcp.validation.environment": "ไม่สามารถแยกวิเคราะห์บรรทัด environment: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "ต้องลงทะเบียนไคลเอนต์",
|
||||
|
||||
"settings.permissions.title": "สิทธิ์",
|
||||
"settings.permissions.description": "ควบคุมเครื่องมือที่เซิร์ฟเวอร์สามารถใช้โดยค่าเริ่มต้น",
|
||||
|
||||
@@ -623,6 +623,13 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
|
||||
"settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster",
|
||||
"settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Zaman çizelgesinde kabuk araç bileşenlerini varsayılan olarak genişletilmiş göster",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
|
||||
|
||||
"settings.general.row.wayland.title": "Yerel Wayland kullan",
|
||||
"settings.general.row.wayland.description":
|
||||
"Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.",
|
||||
@@ -752,6 +759,50 @@ export const dict = {
|
||||
"settings.commands.description": "Komut ayarları burada yapılandırılabilecek.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "MCP ayarları burada yapılandırılabilecek.",
|
||||
"settings.mcp.section.featured": "Öne Çıkanlar",
|
||||
"settings.mcp.section.featured.description":
|
||||
"Tek tıkla popüler MCP sunucuları için cilalanmış bir hazır ayar ekleyin.",
|
||||
"settings.mcp.section.configured": "Yapılandırılmış sunucular",
|
||||
"settings.mcp.section.configured.description":
|
||||
"Hangi MCP sunucularının yüklü olduğunu ve nasıl bağlandıklarını görün; artık ihtiyaç duymadıklarınızı kaldırın.",
|
||||
"settings.mcp.section.add": "Sunucu ekle",
|
||||
"settings.mcp.section.add.description": "Kendi yerel veya uzak MCP sunucu yapılandırmanızı oluşturun.",
|
||||
"settings.mcp.type.local": "Yerel",
|
||||
"settings.mcp.type.remote": "Uzak",
|
||||
"settings.mcp.featured.added": "Eklendi",
|
||||
"settings.mcp.action.add": "Sunucu ekle",
|
||||
"settings.mcp.action.remove": "Kaldır",
|
||||
"settings.mcp.state.enabled": "Etkin",
|
||||
"settings.mcp.state.disabled": "Devre Dışı",
|
||||
"settings.mcp.form.type.label": "Bağlantı türü",
|
||||
"settings.mcp.form.name.label": "Sunucu adı",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "Uzak URL",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "Komut",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "Başlıklar",
|
||||
"settings.mcp.form.headers.description":
|
||||
"İsteğe bağlı. Her satıra bir başlık olacak şekilde KEY: value biçiminde ekleyin.",
|
||||
"settings.mcp.form.environment.label": "Ortam",
|
||||
"settings.mcp.form.environment.description":
|
||||
"İsteğe bağlı. Her satıra bir değişken olacak şekilde KEY=value biçiminde ekleyin.",
|
||||
"settings.mcp.form.timeout.label": "Zaman aşımı (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "MCP sunucusu eklendi",
|
||||
"settings.mcp.toast.added.description": "{{name}}, MCP ayarlarınıza kaydedildi.",
|
||||
"settings.mcp.toast.updated.title": "MCP sunucusu güncellendi",
|
||||
"settings.mcp.toast.updated.description": "{{name}} artık {{state}}.",
|
||||
"settings.mcp.toast.removed.title": "MCP sunucusu kaldırıldı",
|
||||
"settings.mcp.toast.removed.description": "{{name}}, MCP ayarlarınızdan kaldırıldı.",
|
||||
"settings.mcp.validation.name": "Kaydetmeden önce bir sunucu adı girin.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} zaten yapılandırılmış.",
|
||||
"settings.mcp.validation.url": "Bir uzak MCP sunucu URL'si girin.",
|
||||
"settings.mcp.validation.command": "Yerel MCP sunucusunu başlatmak için kullanılan komutu girin.",
|
||||
"settings.mcp.validation.timeout": "Zaman aşımı pozitif bir tam sayı olmalıdır.",
|
||||
"settings.mcp.validation.headers": "Başlık satırı ayrıştırılamadı: {{line}}",
|
||||
"settings.mcp.validation.environment": "Ortam satırı ayrıştırılamadı: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "İstemci kaydı gerekiyor",
|
||||
|
||||
"settings.permissions.title": "İzinler",
|
||||
"settings.permissions.description": "Sunucunun varsayılan olarak hangi araçları kullanabileceğini kontrol edin.",
|
||||
|
||||
@@ -607,6 +607,10 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
|
||||
"settings.general.row.font.title": "字体",
|
||||
"settings.general.row.font.description": "自定义代码块使用的等宽字体",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
|
||||
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
|
||||
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
|
||||
"settings.general.row.wayland.title": "使用原生 Wayland",
|
||||
"settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。",
|
||||
"settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。",
|
||||
@@ -734,6 +738,46 @@ export const dict = {
|
||||
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "MCP 设置将在此处可配置。",
|
||||
"settings.mcp.section.featured": "精选",
|
||||
"settings.mcp.section.featured.description": "一键添加经过精心优化的热门 MCP 服务器预设。",
|
||||
"settings.mcp.section.configured": "已配置的服务器",
|
||||
"settings.mcp.section.configured.description": "查看已安装的 MCP 服务器及其连接方式,并移除不再需要的服务器。",
|
||||
"settings.mcp.section.add": "添加服务器",
|
||||
"settings.mcp.section.add.description": "创建您自己的本地或远程 MCP 服务器配置。",
|
||||
"settings.mcp.type.local": "本地",
|
||||
"settings.mcp.type.remote": "远程",
|
||||
"settings.mcp.featured.added": "已添加",
|
||||
"settings.mcp.action.add": "添加服务器",
|
||||
"settings.mcp.action.remove": "移除",
|
||||
"settings.mcp.state.enabled": "已启用",
|
||||
"settings.mcp.state.disabled": "已禁用",
|
||||
"settings.mcp.form.type.label": "连接类型",
|
||||
"settings.mcp.form.name.label": "服务器名称",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "远程 URL",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "命令",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "请求头",
|
||||
"settings.mcp.form.headers.description": "可选。每行添加一个请求头,格式为 KEY: value。",
|
||||
"settings.mcp.form.environment.label": "环境变量",
|
||||
"settings.mcp.form.environment.description": "可选。每行添加一个变量,格式为 KEY=value。",
|
||||
"settings.mcp.form.timeout.label": "超时 (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "MCP 服务器已添加",
|
||||
"settings.mcp.toast.added.description": "{{name}} 已保存到您的 MCP 设置中。",
|
||||
"settings.mcp.toast.updated.title": "MCP 服务器已更新",
|
||||
"settings.mcp.toast.updated.description": "{{name}} 现已{{state}}。",
|
||||
"settings.mcp.toast.removed.title": "MCP 服务器已移除",
|
||||
"settings.mcp.toast.removed.description": "{{name}} 已从您的 MCP 设置中移除。",
|
||||
"settings.mcp.validation.name": "保存前请输入服务器名称。",
|
||||
"settings.mcp.validation.duplicate": "{{name}} 已配置。",
|
||||
"settings.mcp.validation.url": "请输入远程 MCP 服务器 URL。",
|
||||
"settings.mcp.validation.command": "请输入用于启动本地 MCP 服务器的命令。",
|
||||
"settings.mcp.validation.timeout": "超时必须为正整数。",
|
||||
"settings.mcp.validation.headers": "无法解析请求头行:{{line}}",
|
||||
"settings.mcp.validation.environment": "无法解析环境行:{{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "需要客户端注册",
|
||||
|
||||
"settings.permissions.title": "权限",
|
||||
"settings.permissions.description": "控制服务器默认可以使用哪些工具。",
|
||||
|
||||
@@ -603,6 +603,10 @@ export const dict = {
|
||||
"settings.general.row.font.title": "字型",
|
||||
"settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "展開 shell 工具區塊",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
|
||||
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
|
||||
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
|
||||
"settings.general.row.wayland.title": "使用原生 Wayland",
|
||||
"settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。",
|
||||
"settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。",
|
||||
@@ -727,6 +731,46 @@ export const dict = {
|
||||
"settings.commands.description": "命令設定將在此處可設定。",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "MCP 設定將在此處可設定。",
|
||||
"settings.mcp.section.featured": "精選",
|
||||
"settings.mcp.section.featured.description": "一鍵新增熱門 MCP 伺服器的精選預設設定。",
|
||||
"settings.mcp.section.configured": "已設定的伺服器",
|
||||
"settings.mcp.section.configured.description": "查看已安裝的 MCP 伺服器及其連線方式,並移除不再需要的伺服器。",
|
||||
"settings.mcp.section.add": "新增伺服器",
|
||||
"settings.mcp.section.add.description": "建立您自己的本機或遠端 MCP 伺服器設定。",
|
||||
"settings.mcp.type.local": "本機",
|
||||
"settings.mcp.type.remote": "遠端",
|
||||
"settings.mcp.featured.added": "已新增",
|
||||
"settings.mcp.action.add": "新增伺服器",
|
||||
"settings.mcp.action.remove": "移除",
|
||||
"settings.mcp.state.enabled": "已啟用",
|
||||
"settings.mcp.state.disabled": "已停用",
|
||||
"settings.mcp.form.type.label": "連線類型",
|
||||
"settings.mcp.form.name.label": "伺服器名稱",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "遠端 URL",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "指令",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "標頭",
|
||||
"settings.mcp.form.headers.description": "選用。每行新增一個標頭,格式為 KEY: value。",
|
||||
"settings.mcp.form.environment.label": "環境變數",
|
||||
"settings.mcp.form.environment.description": "選用。每行新增一個變數,格式為 KEY=value。",
|
||||
"settings.mcp.form.timeout.label": "逾時 (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "已新增 MCP 伺服器",
|
||||
"settings.mcp.toast.added.description": "{{name}} 已儲存至您的 MCP 設定。",
|
||||
"settings.mcp.toast.updated.title": "MCP 伺服器已更新",
|
||||
"settings.mcp.toast.updated.description": "{{name}} 現已{{state}}。",
|
||||
"settings.mcp.toast.removed.title": "已移除 MCP 伺服器",
|
||||
"settings.mcp.toast.removed.description": "{{name}} 已從您的 MCP 設定中移除。",
|
||||
"settings.mcp.validation.name": "儲存前請輸入伺服器名稱。",
|
||||
"settings.mcp.validation.duplicate": "{{name}} 已經設定過了。",
|
||||
"settings.mcp.validation.url": "請輸入遠端 MCP 伺服器 URL。",
|
||||
"settings.mcp.validation.command": "請輸入用於啟動本機 MCP 伺服器的指令。",
|
||||
"settings.mcp.validation.timeout": "逾時必須是正整數。",
|
||||
"settings.mcp.validation.headers": "無法解析標頭行:{{line}}",
|
||||
"settings.mcp.validation.environment": "無法解析環境變數行:{{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "需要用戶端註冊",
|
||||
|
||||
"settings.permissions.title": "權限",
|
||||
"settings.permissions.description": "控制伺服器預設可以使用哪些工具。",
|
||||
|
||||
@@ -31,17 +31,16 @@ import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { same } from "@opencode-ai/util/array"
|
||||
import { isEditableTarget } from "@/utils/dom"
|
||||
import { createOpenReviewFile } from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { same } from "@/utils/same"
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
|
||||
@@ -119,13 +118,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
return
|
||||
}
|
||||
const beforeTop = el.scrollTop
|
||||
const beforeHeight = el.scrollHeight
|
||||
fn()
|
||||
// SolidJS updates the DOM synchronously. Force reflow so the browser
|
||||
// processes the new layout, then restore scrollTop before paint.
|
||||
// With column-reverse + overflow-anchor:none the same scrollTop value
|
||||
// keeps the same distance from the bottom — no delta math needed.
|
||||
void el.scrollHeight
|
||||
el.scrollTop = beforeTop
|
||||
requestAnimationFrame(() => {
|
||||
const delta = el.scrollHeight - beforeHeight
|
||||
if (!delta) return
|
||||
el.scrollTop = beforeTop + delta
|
||||
})
|
||||
}
|
||||
|
||||
const backfillTurns = () => {
|
||||
@@ -208,8 +207,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
if (!input.userScrolled()) return
|
||||
const el = input.scroller()
|
||||
if (!el) return
|
||||
// With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop
|
||||
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
|
||||
if (el.scrollTop >= turnScrollThreshold) return
|
||||
|
||||
const start = turnStart()
|
||||
if (start > 0) {
|
||||
@@ -287,6 +285,7 @@ export default function Page() {
|
||||
bottom: true,
|
||||
},
|
||||
})
|
||||
|
||||
const composer = createSessionComposerState()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
@@ -431,8 +430,20 @@ export default function Page() {
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "session" as "session" | "turn",
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
createComputed((prev) => {
|
||||
const key = sessionKey()
|
||||
if (key !== prev) {
|
||||
setStore("deferRender", true)
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => setStore("deferRender", false), 0)
|
||||
})
|
||||
}
|
||||
return key
|
||||
}, sessionKey())
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||
|
||||
@@ -443,6 +454,11 @@ export default function Page() {
|
||||
return "main"
|
||||
})
|
||||
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!store.messageId) return lastUserMessage()
|
||||
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
|
||||
return found ?? lastUserMessage()
|
||||
})
|
||||
const setActiveMessage = (message: UserMessage | undefined) => {
|
||||
setStore("messageId", message?.id)
|
||||
}
|
||||
@@ -604,6 +620,11 @@ export default function Page() {
|
||||
saveLabel: language.t("common.save"),
|
||||
}))
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null | undefined) => {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
|
||||
}
|
||||
|
||||
const deepActiveElement = () => {
|
||||
let current: Element | null = document.activeElement
|
||||
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
|
||||
@@ -699,7 +720,6 @@ export default function Page() {
|
||||
showAllFiles,
|
||||
tabForPath: file.tab,
|
||||
openTab: tabs().open,
|
||||
setActive: tabs().setActive,
|
||||
loadFile: file.load,
|
||||
})
|
||||
|
||||
@@ -734,35 +754,12 @@ export default function Page() {
|
||||
loadingClass: string
|
||||
emptyClass: string
|
||||
}) => (
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
||||
>
|
||||
<Show when={!store.deferRender}>
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
@@ -779,39 +776,64 @@ export default function Page() {
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
)
|
||||
|
||||
const reviewPanel = () => (
|
||||
@@ -1022,10 +1044,7 @@ export default function Page() {
|
||||
const updateScrollState = (el: HTMLDivElement) => {
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
const overflow = max > 1
|
||||
// If auto-scroll is tracking the bottom, always report bottom: true
|
||||
// to prevent the scroll-down arrow from flashing during height animations
|
||||
// With column-reverse, scrollTop=0 is at the bottom
|
||||
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
|
||||
const bottom = !overflow || el.scrollTop >= max - 2
|
||||
|
||||
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
|
||||
setUi("scroll", { overflow, bottom })
|
||||
@@ -1048,7 +1067,7 @@ export default function Page() {
|
||||
|
||||
const resumeScroll = () => {
|
||||
setStore("messageId", undefined)
|
||||
autoScroll.smoothScrollToBottom()
|
||||
autoScroll.forceScrollToBottom()
|
||||
clearMessageHash()
|
||||
|
||||
const el = scroller
|
||||
@@ -1116,8 +1135,9 @@ export default function Page() {
|
||||
|
||||
const el = scroller
|
||||
const delta = next - dockHeight
|
||||
// With column-reverse, near bottom = scrollTop near 0
|
||||
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
|
||||
const stick = el
|
||||
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
|
||||
: false
|
||||
|
||||
dockHeight = next
|
||||
|
||||
@@ -1183,49 +1203,50 @@ export default function Page() {
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
onPreserveScrollAnchor={autoScroll.preserve}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
<Show when={activeMessage()}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
turnStart={historyWindow.turnStart()}
|
||||
historyMore={historyMore()}
|
||||
historyLoading={historyLoading()}
|
||||
onLoadEarlier={() => {
|
||||
void historyWindow.loadAndReveal()
|
||||
}}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
/>
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
turnStart={historyWindow.turnStart()}
|
||||
historyMore={historyMore()}
|
||||
historyLoading={historyLoading()}
|
||||
onLoadEarlier={() => {
|
||||
void historyWindow.loadAndReveal()
|
||||
}}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<NewSessionView
|
||||
@@ -1251,6 +1272,7 @@ export default function Page() {
|
||||
|
||||
<SessionComposerRegion
|
||||
state={composer}
|
||||
ready={!store.deferRender && messagesReady()}
|
||||
centered={centered()}
|
||||
inputRef={(el) => {
|
||||
inputRef = el
|
||||
|
||||
@@ -1,29 +1,3 @@
|
||||
/**
|
||||
* Composer Island migration tracker
|
||||
*
|
||||
* Goal
|
||||
* - Replace the split composer stack (PromptInput + question/permission/todo docks)
|
||||
* with a single morphing ComposerIsland + app runtime adapter.
|
||||
*
|
||||
* Current status
|
||||
* - [x] Storybook prototype with morphing surfaces exists (`packages/ui/src/components/composer-island.tsx`).
|
||||
* - [ ] App still renders the existing production stack (`session-composer-region` + `prompt-input`).
|
||||
*
|
||||
* Feature parity checklist
|
||||
* - [ ] Submit pipeline parity (session/worktree/create, optimistic user message, abort, restore on error).
|
||||
* - [x] Runtime adapter API boundary in island (`runtime.submit`, `runtime.abort`, lookup, permission/question handlers).
|
||||
* - [x] Shell mode wiring parity (single mode source for tray + editor).
|
||||
* - [x] Cursor scroll-into-view behavior in island editor.
|
||||
* - [x] Attachment parity in island UI: image + PDF support and file picker wiring.
|
||||
* - [x] Drag/drop parity in island UI: attachment drop + `file:` text drop into @mention.
|
||||
* - [x] Keyboard parity in island UI: mod+u, ctrl+g, ctrl+n/ctrl+p, popover navigation.
|
||||
* - [x] Auto-accept + stop/send state parity in island UI.
|
||||
* - [x] Async @mention/slash sourcing parity in island via runtime search hooks.
|
||||
* - [ ] Context item parity (comment chips/open behavior is in progress; app comment wiring still pending).
|
||||
* - [ ] Question flow parity (island submit/reject hooks + sending locks done; app SDK/cache wiring pending).
|
||||
* - [ ] Permission flow parity (island responding lock done; app tool-description wiring pending).
|
||||
* - [x] Todo parity in island UI (in-progress pulse + auto-scroll active item).
|
||||
*/
|
||||
export { SessionComposerRegion } from "./session-composer-region"
|
||||
export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
|
||||
export type { SessionComposerState } from "./session-composer-state"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Show, createMemo, createSignal, createEffect } from "solid-js"
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { useElementHeight } from "@opencode-ai/ui/hooks"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
@@ -9,12 +9,11 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
|
||||
import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock"
|
||||
|
||||
const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 }
|
||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
ready: boolean
|
||||
centered: boolean
|
||||
inputRef: (el: HTMLDivElement) => void
|
||||
newSessionWorktree: string
|
||||
@@ -22,6 +21,23 @@ export function SessionComposerRegion(props: {
|
||||
onSubmit: () => void
|
||||
onResponseSubmit: () => void
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
visualDuration?: number
|
||||
bounce?: number
|
||||
dockOpenVisualDuration?: number
|
||||
dockOpenBounce?: number
|
||||
dockCloseVisualDuration?: number
|
||||
dockCloseBounce?: number
|
||||
drawerExpandVisualDuration?: number
|
||||
drawerExpandBounce?: number
|
||||
drawerCollapseVisualDuration?: number
|
||||
drawerCollapseBounce?: number
|
||||
subtitleDuration?: number
|
||||
subtitleTravel?: number
|
||||
subtitleEdge?: number
|
||||
countDuration?: number
|
||||
countMask?: number
|
||||
countMaskHeight?: number
|
||||
countWidthDuration?: number
|
||||
}) {
|
||||
const params = useParams()
|
||||
const prompt = usePrompt()
|
||||
@@ -47,15 +63,73 @@ export function SessionComposerRegion(props: {
|
||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
const open = createMemo(() => props.state.dock() && !props.state.closing())
|
||||
const progress = useSpring(
|
||||
() => (open() ? 1 : 0),
|
||||
DOCK_SPRING,
|
||||
const [gate, setGate] = createStore({
|
||||
ready: false,
|
||||
})
|
||||
let timer: number | undefined
|
||||
let frame: number | undefined
|
||||
|
||||
const clear = () => {
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
if (frame !== undefined) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
sessionKey()
|
||||
const ready = props.ready
|
||||
const delay = 140
|
||||
|
||||
clear()
|
||||
setGate("ready", false)
|
||||
if (!ready) return
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
timer = window.setTimeout(() => {
|
||||
setGate("ready", true)
|
||||
timer = undefined
|
||||
}, delay)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
|
||||
const config = createMemo(() =>
|
||||
open()
|
||||
? {
|
||||
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
)
|
||||
const dock = createMemo(() => props.state.dock() || progress() > 0.001)
|
||||
const progress = useSpring(() => (open() ? 1 : 0), config)
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
||||
const height = useElementHeight(contentRef, 320)
|
||||
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef()
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -105,10 +179,10 @@ export function SessionComposerRegion(props: {
|
||||
<div
|
||||
classList={{
|
||||
"overflow-hidden": true,
|
||||
"pointer-events-none": progress() < 0.98,
|
||||
"pointer-events-none": value() < 0.98,
|
||||
}}
|
||||
style={{
|
||||
"max-height": `${full() * progress()}px`,
|
||||
"max-height": `${full() * value()}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={setContentRef}>
|
||||
@@ -117,7 +191,20 @@ export function SessionComposerRegion(props: {
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={progress()}
|
||||
dockProgress={value()}
|
||||
visualDuration={props.visualDuration}
|
||||
bounce={props.bounce}
|
||||
expandVisualDuration={props.drawerExpandVisualDuration}
|
||||
expandBounce={props.drawerExpandBounce}
|
||||
collapseVisualDuration={props.drawerCollapseVisualDuration}
|
||||
collapseBounce={props.drawerCollapseBounce}
|
||||
subtitleDuration={props.subtitleDuration}
|
||||
subtitleTravel={props.subtitleTravel}
|
||||
subtitleEdge={props.subtitleEdge}
|
||||
countDuration={props.countDuration}
|
||||
countMask={props.countMask}
|
||||
countMaskHeight={props.countMaskHeight}
|
||||
countWidthDuration={props.countWidthDuration}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,7 +214,7 @@ export function SessionComposerRegion(props: {
|
||||
"relative z-10": true,
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${-36 * progress()}px`,
|
||||
"margin-top": `${-36 * value()}px`,
|
||||
}}
|
||||
>
|
||||
<PromptInput
|
||||
|
||||
@@ -29,11 +29,7 @@ export function createSessionComposerBlocked() {
|
||||
})
|
||||
}
|
||||
|
||||
export function createSessionComposerState(
|
||||
options?: {
|
||||
closeMs?: number | (() => number)
|
||||
},
|
||||
) {
|
||||
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
|
||||
@@ -6,15 +6,9 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { useElementHeight } from "@opencode-ai/ui/hooks"
|
||||
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const COLLAPSE_SPRING = { visualDuration: 0.3, bounce: 0 }
|
||||
export const COLLAPSED_HEIGHT = 78
|
||||
const SUBTITLE = { duration: 600, travel: 25, edge: 17 }
|
||||
const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 }
|
||||
|
||||
function dot(status: Todo["status"]) {
|
||||
if (status !== "in_progress") return undefined
|
||||
return (
|
||||
@@ -46,6 +40,19 @@ export function SessionTodoDock(props: {
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
dockProgress?: number
|
||||
visualDuration?: number
|
||||
bounce?: number
|
||||
expandVisualDuration?: number
|
||||
expandBounce?: number
|
||||
collapseVisualDuration?: number
|
||||
collapseBounce?: number
|
||||
subtitleDuration?: number
|
||||
subtitleTravel?: number
|
||||
subtitleEdge?: number
|
||||
countDuration?: number
|
||||
countMask?: number
|
||||
countMaskHeight?: number
|
||||
countWidthDuration?: number
|
||||
}) {
|
||||
const [store, setStore] = createStore({
|
||||
collapsed: false,
|
||||
@@ -66,12 +73,39 @@ export function SessionTodoDock(props: {
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
const collapse = useSpring(() => (store.collapsed ? 1 : 0), COLLAPSE_SPRING)
|
||||
const shut = createMemo(() => 1 - (props.dockProgress ?? 1))
|
||||
const hide = createMemo(() => Math.max(collapse(), shut()))
|
||||
const config = createMemo(() =>
|
||||
store.collapsed
|
||||
? {
|
||||
visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.collapseBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.expandBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
)
|
||||
const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
|
||||
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
|
||||
const shut = createMemo(() => 1 - dock())
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const hide = createMemo(() => Math.max(value(), shut()))
|
||||
const off = createMemo(() => hide() > 0.98)
|
||||
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
const height = useElementHeight(() => contentRef, 320)
|
||||
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
return (
|
||||
<DockTray
|
||||
@@ -79,7 +113,7 @@ export function SessionTodoDock(props: {
|
||||
style={{
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "hidden",
|
||||
"max-height": `${Math.max(COLLAPSED_HEIGHT, full() - collapse() * (full() - COLLAPSED_HEIGHT))}px`,
|
||||
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
@@ -99,12 +133,12 @@ export function SessionTodoDock(props: {
|
||||
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
|
||||
aria-label={label()}
|
||||
style={{
|
||||
"--tool-motion-odometer-ms": `${COUNT.duration}ms`,
|
||||
"--tool-motion-mask": `${COUNT.mask}%`,
|
||||
"--tool-motion-mask-height": `${COUNT.maskHeight}px`,
|
||||
"--tool-motion-spring-ms": `${COUNT.widthDuration}ms`,
|
||||
opacity: `${1 - shut()}`,
|
||||
filter: shut() > 0.01 ? `blur(${shut() * 2}px)` : "none",
|
||||
"--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
|
||||
"--tool-motion-mask": `${props.countMask ?? 18}%`,
|
||||
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
|
||||
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
|
||||
}}
|
||||
>
|
||||
<AnimatedNumber value={done()} />
|
||||
@@ -123,9 +157,9 @@ export function SessionTodoDock(props: {
|
||||
<TextReveal
|
||||
class="text-14-regular text-text-base cursor-default"
|
||||
text={store.collapsed ? preview() : undefined}
|
||||
duration={SUBTITLE.duration}
|
||||
travel={SUBTITLE.travel}
|
||||
edge={SUBTITLE.edge}
|
||||
duration={props.subtitleDuration ?? 600}
|
||||
travel={props.subtitleTravel ?? 25}
|
||||
edge={props.subtitleEdge ?? 17}
|
||||
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
growOnly
|
||||
@@ -139,7 +173,7 @@ export function SessionTodoDock(props: {
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
style={{ transform: `rotate(${collapse() * 180}deg)` }}
|
||||
style={{ transform: `rotate(${turn() * 180}deg)` }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -155,15 +189,14 @@ export function SessionTodoDock(props: {
|
||||
|
||||
<div
|
||||
data-slot="session-todo-list"
|
||||
class="pb-2"
|
||||
aria-hidden={store.collapsed}
|
||||
aria-hidden={store.collapsed || off()}
|
||||
classList={{
|
||||
"pointer-events-none": hide() > 0.1,
|
||||
}}
|
||||
style={{
|
||||
opacity: `${1 - hide()}`,
|
||||
filter: hide() > 0.01 ? `blur(${hide() * 2}px)` : "none",
|
||||
visibility: hide() > 0.98 ? "hidden" : "visible",
|
||||
visibility: off() ? "hidden" : "visible",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
|
||||
}}
|
||||
>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
@@ -249,7 +282,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
"--checkbox-align": "flex-start",
|
||||
"--checkbox-offset": "1px",
|
||||
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
opacity: todo().status === "pending" ? "0.5" : "1",
|
||||
opacity: todo().status === "pending" ? "0.94" : "1",
|
||||
}}
|
||||
>
|
||||
<TextStrikethrough
|
||||
@@ -259,11 +292,12 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
style={{
|
||||
"line-height": "var(--line-height-normal)",
|
||||
transition:
|
||||
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
color:
|
||||
todo().status === "completed" || todo().status === "cancelled"
|
||||
? "var(--text-weak)"
|
||||
: "var(--text-strong)",
|
||||
opacity: todo().status === "pending" ? "0.92" : "1",
|
||||
}}
|
||||
/>
|
||||
</Checkbox>
|
||||
|
||||
@@ -11,13 +11,12 @@ describe("createOpenReviewFile", () => {
|
||||
return `file://${path}`
|
||||
},
|
||||
openTab: (tab) => calls.push(`open:${tab}`),
|
||||
setActive: (tab) => calls.push(`active:${tab}`),
|
||||
loadFile: (path) => calls.push(`load:${path}`),
|
||||
})
|
||||
|
||||
openReviewFile("src/a.ts")
|
||||
|
||||
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"])
|
||||
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -24,20 +24,15 @@ export const createOpenReviewFile = (input: {
|
||||
showAllFiles: () => void
|
||||
tabForPath: (path: string) => string
|
||||
openTab: (tab: string) => void
|
||||
setActive: (tab: string) => void
|
||||
loadFile: (path: string) => any | Promise<void>
|
||||
}) => {
|
||||
return (path: string) => {
|
||||
batch(() => {
|
||||
input.showAllFiles()
|
||||
const maybePromise = input.loadFile(path)
|
||||
const open = () => {
|
||||
const tab = input.tabForPath(path)
|
||||
input.openTab(tab)
|
||||
input.setActive(tab)
|
||||
}
|
||||
if (maybePromise instanceof Promise) maybePromise.then(open)
|
||||
else open()
|
||||
const openTab = () => input.openTab(input.tabForPath(path))
|
||||
if (maybePromise instanceof Promise) maybePromise.then(openTab)
|
||||
else openTab()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,11 +49,11 @@ describe("shouldMarkBoundaryGesture", () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("does not mark when scroller can consume movement", () => {
|
||||
test("does not mark when nested scroller can consume movement", () => {
|
||||
expect(
|
||||
shouldMarkBoundaryGesture({
|
||||
delta: 20,
|
||||
scrollTop: 300,
|
||||
scrollTop: 200,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 400,
|
||||
}),
|
||||
|
||||
@@ -14,8 +14,8 @@ export const shouldMarkBoundaryGesture = (input: {
|
||||
if (max <= 1) return true
|
||||
if (!input.delta) return false
|
||||
|
||||
const top = Math.max(0, Math.min(max, input.scrollTop))
|
||||
if (input.delta < 0) return -input.delta > top
|
||||
const bottom = max - top
|
||||
return input.delta > bottom
|
||||
if (input.delta < 0) return input.scrollTop + input.delta <= 0
|
||||
|
||||
const remaining = max - input.scrollTop
|
||||
return input.delta > remaining
|
||||
}
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import {
|
||||
For,
|
||||
Index,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
on,
|
||||
onCleanup,
|
||||
Show,
|
||||
startTransition,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { SessionTimelineHeader } from "@/pages/session/session-timeline-header"
|
||||
|
||||
type MessageComment = {
|
||||
path: string
|
||||
@@ -37,9 +33,7 @@ type MessageComment = {
|
||||
}
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
|
||||
const isDefaultSessionTitle = (title?: string) =>
|
||||
!!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
const messageComments = (parts: Part[]): MessageComment[] =>
|
||||
parts.flatMap((part) => {
|
||||
@@ -116,8 +110,6 @@ function createTimelineStaging(input: TimelineStageInput) {
|
||||
completedSession: "",
|
||||
count: 0,
|
||||
})
|
||||
const [readySession, setReadySession] = createSignal("")
|
||||
let active = ""
|
||||
|
||||
const stagedCount = createMemo(() => {
|
||||
const total = input.messages().length
|
||||
@@ -142,46 +134,23 @@ function createTimelineStaging(input: TimelineStageInput) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
const scheduleReady = (sessionKey: string) => {
|
||||
if (input.sessionKey() !== sessionKey) return
|
||||
if (readySession() === sessionKey) return
|
||||
setReadySession(sessionKey)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
|
||||
([sessionKey, isWindowed, total]) => {
|
||||
const switched = active !== sessionKey
|
||||
if (switched) {
|
||||
active = sessionKey
|
||||
setReadySession("")
|
||||
}
|
||||
|
||||
const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
|
||||
const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
|
||||
|
||||
if (staging && !switched && shouldStage && frame !== undefined) return
|
||||
|
||||
cancel()
|
||||
|
||||
if (shouldStage) setReadySession("")
|
||||
const shouldStage =
|
||||
isWindowed &&
|
||||
total > input.config.init &&
|
||||
state.completedSession !== sessionKey &&
|
||||
state.activeSession !== sessionKey
|
||||
if (!shouldStage) {
|
||||
setState({
|
||||
activeSession: "",
|
||||
completedSession: isWindowed ? sessionKey : state.completedSession,
|
||||
count: total,
|
||||
})
|
||||
if (total <= 0) {
|
||||
setReadySession("")
|
||||
return
|
||||
}
|
||||
if (readySession() !== sessionKey) scheduleReady(sessionKey)
|
||||
setState({ activeSession: "", count: total })
|
||||
return
|
||||
}
|
||||
|
||||
let count = Math.min(total, input.config.init)
|
||||
if (staging) count = Math.min(total, Math.max(count, state.count))
|
||||
setState({ activeSession: sessionKey, count })
|
||||
|
||||
const step = () => {
|
||||
@@ -195,7 +164,6 @@ function createTimelineStaging(input: TimelineStageInput) {
|
||||
if (count >= currentTotal) {
|
||||
setState({ completedSession: sessionKey, activeSession: "" })
|
||||
frame = undefined
|
||||
scheduleReady(sessionKey)
|
||||
return
|
||||
}
|
||||
frame = requestAnimationFrame(step)
|
||||
@@ -209,12 +177,9 @@ function createTimelineStaging(input: TimelineStageInput) {
|
||||
const key = input.sessionKey()
|
||||
return state.activeSession === key && state.completedSession !== key
|
||||
})
|
||||
const ready = createMemo(() => readySession() === input.sessionKey())
|
||||
|
||||
onCleanup(() => {
|
||||
cancel()
|
||||
})
|
||||
return { messages: stagedUserMessages, isStaging, ready }
|
||||
onCleanup(cancel)
|
||||
return { messages: stagedUserMessages, isStaging }
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: {
|
||||
@@ -231,7 +196,6 @@ export function MessageTimeline(props: {
|
||||
onScrollSpyScroll: () => void
|
||||
onTurnBackfillScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
onPreserveScrollAnchor: (target: HTMLElement) => void
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
turnStart: number
|
||||
@@ -246,19 +210,14 @@ export function MessageTimeline(props: {
|
||||
let touchGesture: number | undefined
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const settings = useSettings()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
const trigger = (target: EventTarget | null) => {
|
||||
const next =
|
||||
target instanceof Element
|
||||
? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]')
|
||||
: undefined
|
||||
if (!(next instanceof HTMLElement)) return
|
||||
return next
|
||||
}
|
||||
|
||||
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const sessionMessages = createMemo(() => {
|
||||
@@ -271,20 +230,28 @@ export function MessageTimeline(props: {
|
||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
),
|
||||
)
|
||||
const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
|
||||
const sessionStatus = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return idle
|
||||
return sync.data.session_status[id] ?? idle
|
||||
})
|
||||
const activeMessageID = createMemo(() => {
|
||||
const messages = sessionMessages()
|
||||
const message = pending()
|
||||
if (message?.parentID) {
|
||||
const result = Binary.search(messages, message.parentID, (item) => item.id)
|
||||
const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID)
|
||||
if (parent?.role === "user") return parent.id
|
||||
const parentID = pending()?.parentID
|
||||
if (parentID) {
|
||||
const messages = sessionMessages()
|
||||
const result = Binary.search(messages, parentID, (message) => message.id)
|
||||
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
|
||||
if (message && message.role === "user") return message.id
|
||||
}
|
||||
|
||||
if (sessionStatus() === "idle") return undefined
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "user") return messages[i].id
|
||||
const status = sessionStatus()
|
||||
if (status.type !== "idle") {
|
||||
const messages = sessionMessages()
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "user") return messages[i].id
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
@@ -292,19 +259,9 @@ export function MessageTimeline(props: {
|
||||
if (!id) return
|
||||
return sync.session.get(id)
|
||||
})
|
||||
const titleValue = createMemo(() => {
|
||||
const title = info()?.title
|
||||
if (!title) return
|
||||
if (isDefaultSessionTitle(title)) return language.t("command.session.new")
|
||||
return title
|
||||
})
|
||||
const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title))
|
||||
const headerTitle = createMemo(
|
||||
() => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined),
|
||||
)
|
||||
const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0))
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const showHeader = createMemo(() => !!(headerTitle() || parentID()))
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const stageCfg = { init: 1, batch: 3 }
|
||||
const staging = createTimelineStaging({
|
||||
sessionKey,
|
||||
@@ -312,7 +269,212 @@ export function MessageTimeline(props: {
|
||||
messages: () => props.renderedUserMessages,
|
||||
config: stageCfg,
|
||||
})
|
||||
const rendered = createMemo(() => staging.messages().map((message) => message.id))
|
||||
|
||||
const [title, setTitle] = createStore({
|
||||
draft: "",
|
||||
editing: false,
|
||||
saving: false,
|
||||
menuOpen: false,
|
||||
pendingRename: false,
|
||||
})
|
||||
let titleRef: HTMLInputElement | undefined
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const openTitleEditor = () => {
|
||||
if (!sessionID()) return
|
||||
setTitle({ editing: true, draft: titleValue() ?? "" })
|
||||
requestAnimationFrame(() => {
|
||||
titleRef?.focus()
|
||||
titleRef?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const closeTitleEditor = () => {
|
||||
if (title.saving) return
|
||||
setTitle({ editing: false, saving: false })
|
||||
}
|
||||
|
||||
const saveTitleEditor = async () => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
if (title.saving) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (titleValue() ?? "")) {
|
||||
setTitle({ editing: false, saving: false })
|
||||
return
|
||||
}
|
||||
|
||||
setTitle("saving", true)
|
||||
await sdk.client.session
|
||||
.update({ sessionID: id, title: next })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === id)
|
||||
if (index !== -1) draft.session[index].title = next
|
||||
}),
|
||||
)
|
||||
setTitle({ editing: false, saving: false })
|
||||
})
|
||||
.catch((err) => {
|
||||
setTitle("saving", false)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||
if (params.id !== sessionID) return
|
||||
if (parentID) {
|
||||
navigate(`/${params.dir}/session/${parentID}`)
|
||||
return
|
||||
}
|
||||
if (nextSessionID) {
|
||||
navigate(`/${params.dir}/session/${nextSessionID}`)
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
|
||||
const archiveSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return
|
||||
|
||||
const sessions = sync.data.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
await sdk.client.session
|
||||
.update({ sessionID, time: { archived: Date.now() } })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === sessionID)
|
||||
if (index !== -1) draft.session.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return false
|
||||
|
||||
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
|
||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk.client.session
|
||||
.delete({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!result) return false
|
||||
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const removed = new Set<string>([sessionID])
|
||||
|
||||
const byParent = new Map<string, string[]>()
|
||||
for (const item of draft.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
const existing = byParent.get(parentID)
|
||||
if (existing) {
|
||||
existing.push(item.id)
|
||||
continue
|
||||
}
|
||||
byParent.set(parentID, [item.id])
|
||||
}
|
||||
|
||||
const stack = [sessionID]
|
||||
while (stack.length) {
|
||||
const parentID = stack.pop()
|
||||
if (!parentID) continue
|
||||
|
||||
const children = byParent.get(parentID)
|
||||
if (!children) continue
|
||||
|
||||
for (const child of children) {
|
||||
if (removed.has(child)) continue
|
||||
removed.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
||||
}),
|
||||
)
|
||||
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
return true
|
||||
}
|
||||
|
||||
const navigateParent = () => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
navigate(`/${params.dir}/session/${id}`)
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: name() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -336,16 +498,6 @@ export function MessageTimeline(props: {
|
||||
<Icon name="arrow-down-to-line" />
|
||||
</button>
|
||||
</div>
|
||||
<SessionTimelineHeader
|
||||
centered={props.centered}
|
||||
showHeader={showHeader}
|
||||
sessionKey={sessionKey}
|
||||
sessionID={sessionID}
|
||||
parentID={parentID}
|
||||
titleValue={titleValue}
|
||||
headerTitle={headerTitle}
|
||||
placeholderTitle={placeholderTitle}
|
||||
/>
|
||||
<ScrollView
|
||||
viewportRef={props.setScrollRef}
|
||||
onWheel={(e) => {
|
||||
@@ -380,18 +532,9 @@ export function MessageTimeline(props: {
|
||||
touchGesture = undefined
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
const next = trigger(e.target)
|
||||
if (next) props.onPreserveScrollAnchor(next)
|
||||
|
||||
if (e.target !== e.currentTarget) return
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return
|
||||
const next = trigger(e.target)
|
||||
if (!next) return
|
||||
props.onPreserveScrollAnchor(next)
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
props.onScheduleScrollState(e.currentTarget)
|
||||
props.onTurnBackfillScroll()
|
||||
@@ -400,21 +543,131 @@ export function MessageTimeline(props: {
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
if (props.isDesktop) props.onScrollSpyScroll()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
props.onAutoScrollInteraction(e)
|
||||
}}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
style={{
|
||||
"--session-title-height": showHeader() ? "72px" : "0px",
|
||||
"--session-title-height": showHeader() ? "40px" : "0px",
|
||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div ref={props.setContentRef} class="min-w-0 w-full">
|
||||
<Show when={showHeader()}>
|
||||
<div
|
||||
data-session-title
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||
<Show when={parentID()}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={props.setContentRef}
|
||||
role="log"
|
||||
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
|
||||
style={{ "padding-top": "var(--session-title-height)" }}
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
@@ -439,15 +692,6 @@ export function MessageTimeline(props: {
|
||||
</Show>
|
||||
<For each={rendered()}>
|
||||
{(messageID) => {
|
||||
// Capture at creation time: animate only messages added after the
|
||||
// timeline finishes its initial backfill staging, plus the first
|
||||
// turn while a brand new session is still using its default title.
|
||||
const isNew =
|
||||
staging.ready() ||
|
||||
(defaultTitle() &&
|
||||
sessionStatus() !== "idle" &&
|
||||
props.renderedUserMessages.length === 1 &&
|
||||
messageID === props.renderedUserMessages[0]?.id)
|
||||
const active = createMemo(() => activeMessageID() === messageID)
|
||||
const queued = createMemo(() => {
|
||||
if (active()) return false
|
||||
@@ -456,10 +700,7 @@ export function MessageTimeline(props: {
|
||||
return false
|
||||
})
|
||||
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
|
||||
equals: (a, b) => {
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment)
|
||||
},
|
||||
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
||||
})
|
||||
const commentCount = createMemo(() => comments().length)
|
||||
return (
|
||||
@@ -516,10 +757,10 @@ export function MessageTimeline(props: {
|
||||
messageID={messageID}
|
||||
active={active()}
|
||||
queued={queued()}
|
||||
animate={isNew || active()}
|
||||
status={active() ? sessionStatus() : undefined}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
shellToolDefaultOpen={false}
|
||||
editToolDefaultOpen={false}
|
||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
|
||||
@@ -1,522 +0,0 @@
|
||||
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { errorMessage } from "@/pages/layout/helpers"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
export function SessionTimelineHeader(props: {
|
||||
centered: boolean
|
||||
showHeader: () => boolean
|
||||
sessionKey: () => string
|
||||
sessionID: () => string | undefined
|
||||
parentID: () => string | undefined
|
||||
titleValue: () => string | undefined
|
||||
headerTitle: () => string | undefined
|
||||
placeholderTitle: () => boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const reduce = prefersReducedMotion
|
||||
|
||||
const [title, setTitle] = createStore({
|
||||
draft: "",
|
||||
editing: false,
|
||||
saving: false,
|
||||
menuOpen: false,
|
||||
pendingRename: false,
|
||||
})
|
||||
const [headerText, setHeaderText] = createStore({
|
||||
session: props.sessionKey(),
|
||||
value: props.headerTitle(),
|
||||
prev: undefined as string | undefined,
|
||||
muted: props.placeholderTitle(),
|
||||
prevMuted: false,
|
||||
})
|
||||
let headerAnim: AnimationPlaybackControls | undefined
|
||||
let enterAnim: AnimationPlaybackControls | undefined
|
||||
let leaveAnim: AnimationPlaybackControls | undefined
|
||||
let titleRef: HTMLInputElement | undefined
|
||||
let headerRef: HTMLDivElement | undefined
|
||||
let enterRef: HTMLSpanElement | undefined
|
||||
let leaveRef: HTMLSpanElement | undefined
|
||||
|
||||
const clearHeaderAnim = () => {
|
||||
headerAnim?.stop()
|
||||
headerAnim = undefined
|
||||
}
|
||||
|
||||
const animateHeader = () => {
|
||||
const el = headerRef
|
||||
if (!el) return
|
||||
|
||||
clearHeaderAnim()
|
||||
if (!headerText.muted || reduce()) {
|
||||
el.style.opacity = "1"
|
||||
return
|
||||
}
|
||||
|
||||
headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
|
||||
headerAnim.finished.then(() => {
|
||||
if (headerRef !== el) return
|
||||
clearFadeStyles(el)
|
||||
})
|
||||
}
|
||||
|
||||
const clearTitleAnims = () => {
|
||||
enterAnim?.stop()
|
||||
enterAnim = undefined
|
||||
leaveAnim?.stop()
|
||||
leaveAnim = undefined
|
||||
}
|
||||
|
||||
const settleTitleEnter = () => {
|
||||
if (enterRef) clearFadeStyles(enterRef)
|
||||
}
|
||||
|
||||
const hideLeave = () => {
|
||||
if (!leaveRef) return
|
||||
leaveRef.style.opacity = "0"
|
||||
leaveRef.style.filter = ""
|
||||
leaveRef.style.transform = ""
|
||||
}
|
||||
|
||||
const animateEnterSpan = () => {
|
||||
if (!enterRef) return
|
||||
if (reduce()) {
|
||||
settleTitleEnter()
|
||||
return
|
||||
}
|
||||
enterAnim = animate(
|
||||
enterRef,
|
||||
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
|
||||
FAST_SPRING,
|
||||
)
|
||||
enterAnim.finished.then(() => settleTitleEnter())
|
||||
}
|
||||
|
||||
const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted })
|
||||
|
||||
if (reduce()) {
|
||||
setHeaderText({ prev: undefined, prevMuted: false })
|
||||
hideLeave()
|
||||
settleTitleEnter()
|
||||
return
|
||||
}
|
||||
|
||||
if (leaveRef) {
|
||||
leaveAnim = animate(
|
||||
leaveRef,
|
||||
{ opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
|
||||
FAST_SPRING,
|
||||
)
|
||||
leaveAnim.finished.then(() => {
|
||||
setHeaderText({ prev: undefined, prevMuted: false })
|
||||
hideLeave()
|
||||
})
|
||||
}
|
||||
|
||||
animateEnterSpan()
|
||||
}
|
||||
|
||||
const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
|
||||
animateEnterSpan()
|
||||
}
|
||||
|
||||
const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
|
||||
settleTitleEnter()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(props.showHeader, (show, prev) => {
|
||||
if (!show) {
|
||||
clearHeaderAnim()
|
||||
return
|
||||
}
|
||||
if (show === prev) return
|
||||
animateHeader()
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
|
||||
([nextSession, nextTitle, nextMuted]) => {
|
||||
if (nextSession !== headerText.session) {
|
||||
setHeaderText("session", nextSession)
|
||||
if (nextTitle && nextMuted) {
|
||||
fadeInTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
snapTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
if (nextTitle === headerText.value && nextMuted === headerText.muted) return
|
||||
if (!nextTitle) {
|
||||
snapTitle(undefined, false)
|
||||
return
|
||||
}
|
||||
if (!headerText.value) {
|
||||
fadeInTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
if (title.saving || title.editing) {
|
||||
snapTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
crossfadeTitle(nextTitle, nextMuted)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
clearHeaderAnim()
|
||||
clearTitleAnims()
|
||||
})
|
||||
|
||||
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
props.sessionKey,
|
||||
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const openTitleEditor = () => {
|
||||
if (!props.sessionID()) return
|
||||
setTitle({ editing: true, draft: props.titleValue() ?? "" })
|
||||
requestAnimationFrame(() => {
|
||||
titleRef?.focus()
|
||||
titleRef?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const closeTitleEditor = () => {
|
||||
if (title.saving) return
|
||||
setTitle({ editing: false, saving: false })
|
||||
}
|
||||
|
||||
const saveTitleEditor = async () => {
|
||||
const id = props.sessionID()
|
||||
if (!id) return
|
||||
if (title.saving) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (props.titleValue() ?? "")) {
|
||||
setTitle({ editing: false, saving: false })
|
||||
return
|
||||
}
|
||||
|
||||
setTitle("saving", true)
|
||||
await sdk.client.session
|
||||
.update({ sessionID: id, title: next })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((session) => session.id === id)
|
||||
if (index !== -1) draft.session[index].title = next
|
||||
}),
|
||||
)
|
||||
setTitle({ editing: false, saving: false })
|
||||
})
|
||||
.catch((err) => {
|
||||
setTitle("saving", false)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: toastError(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||
if (params.id !== sessionID) return
|
||||
if (parentID) {
|
||||
navigate(`/${params.dir}/session/${parentID}`)
|
||||
return
|
||||
}
|
||||
if (nextSessionID) {
|
||||
navigate(`/${params.dir}/session/${nextSessionID}`)
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
|
||||
const archiveSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return
|
||||
|
||||
const sessions = sync.data.session ?? []
|
||||
const index = sessions.findIndex((item) => item.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
await sdk.client.session
|
||||
.update({ sessionID, time: { archived: Date.now() } })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((item) => item.id === sessionID)
|
||||
if (index !== -1) draft.session.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: toastError(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return false
|
||||
|
||||
const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
|
||||
const index = sessions.findIndex((item) => item.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk.client.session
|
||||
.delete({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: toastError(err),
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!result) return false
|
||||
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const removed = new Set<string>([sessionID])
|
||||
const byParent = new Map<string, string[]>()
|
||||
|
||||
for (const item of draft.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
|
||||
const existing = byParent.get(parentID)
|
||||
if (existing) {
|
||||
existing.push(item.id)
|
||||
continue
|
||||
}
|
||||
byParent.set(parentID, [item.id])
|
||||
}
|
||||
|
||||
const stack = [sessionID]
|
||||
while (stack.length) {
|
||||
const parentID = stack.pop()
|
||||
if (!parentID) continue
|
||||
|
||||
const children = byParent.get(parentID)
|
||||
if (!children) continue
|
||||
|
||||
for (const child of children) {
|
||||
if (removed.has(child)) continue
|
||||
removed.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
draft.session = draft.session.filter((item) => !removed.has(item.id))
|
||||
}),
|
||||
)
|
||||
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
return true
|
||||
}
|
||||
|
||||
const navigateParent = () => {
|
||||
const id = props.parentID()
|
||||
if (!id) return
|
||||
navigate(`/${params.dir}/session/${id}`)
|
||||
}
|
||||
|
||||
function DialogDeleteSession(input: { sessionID: string }) {
|
||||
const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new"))
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(input.sessionID)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: name() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.showHeader()}>
|
||||
<div
|
||||
data-session-title
|
||||
ref={(el) => {
|
||||
headerRef = el
|
||||
el.style.opacity = "0"
|
||||
}}
|
||||
class="pointer-events-none absolute inset-x-0 top-0 z-30"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-10": true,
|
||||
"px-4 md:px-5": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Show when={props.parentID()}>
|
||||
<div>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!!headerText.value || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}>
|
||||
<span class="grid min-w-0" style={{ overflow: "clip" }}>
|
||||
<span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
|
||||
<span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
|
||||
</span>
|
||||
<span
|
||||
ref={leaveRef}
|
||||
class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
|
||||
style={{ opacity: "0" }}
|
||||
>
|
||||
<span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.sessionID()}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, onMount } from "solid-js"
|
||||
import { messageIdFromHash } from "./message-id-from-hash"
|
||||
|
||||
export { messageIdFromHash } from "./message-id-from-hash"
|
||||
@@ -15,7 +16,7 @@ export const useSessionHashScroll = (input: {
|
||||
setPendingMessage: (value: string | undefined) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
setTurnStart: (value: number) => void
|
||||
autoScroll: { pause: () => void; snapToBottom: () => void }
|
||||
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
anchor: (id: string) => string
|
||||
scheduleScrollState: (el: HTMLDivElement) => void
|
||||
@@ -26,13 +27,18 @@ export const useSessionHashScroll = (input: {
|
||||
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
||||
let pendingKey = ""
|
||||
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const clearMessageHash = () => {
|
||||
if (!window.location.hash) return
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search)
|
||||
if (!location.hash) return
|
||||
navigate(location.pathname + location.search, { replace: true })
|
||||
}
|
||||
|
||||
const updateHash = (id: string) => {
|
||||
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
|
||||
navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
||||
@@ -41,15 +47,15 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
const a = el.getBoundingClientRect()
|
||||
const b = root.getBoundingClientRect()
|
||||
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
|
||||
const inset = Number.isNaN(title) ? 0 : title
|
||||
// With column-reverse, scrollTop is negative — don't clamp to 0
|
||||
const top = a.top - b.top + root.scrollTop - inset
|
||||
const sticky = root.querySelector("[data-session-title]")
|
||||
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
|
||||
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
|
||||
root.scrollTo({ top, behavior })
|
||||
return true
|
||||
}
|
||||
|
||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||
console.log({ message, behavior })
|
||||
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
||||
|
||||
const index = messageIndex().get(message.id) ?? -1
|
||||
@@ -97,9 +103,9 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
|
||||
const applyHash = (behavior: ScrollBehavior) => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
const hash = location.hash.slice(1)
|
||||
if (!hash) {
|
||||
input.autoScroll.snapToBottom()
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
return
|
||||
@@ -123,26 +129,13 @@ export const useSessionHashScroll = (input: {
|
||||
return
|
||||
}
|
||||
|
||||
input.autoScroll.snapToBottom()
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual"
|
||||
}
|
||||
|
||||
const handler = () => {
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
requestAnimationFrame(() => applyHash("auto"))
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", handler)
|
||||
onCleanup(() => window.removeEventListener("hashchange", handler))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
location.hash
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
requestAnimationFrame(() => applyHash("auto"))
|
||||
})
|
||||
@@ -166,6 +159,7 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetId) targetId = messageIdFromHash(location.hash)
|
||||
if (!targetId) return
|
||||
if (input.currentMessageId() === targetId) return
|
||||
|
||||
@@ -177,6 +171,12 @@ export const useSessionHashScroll = (input: {
|
||||
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual"
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
clearMessageHash,
|
||||
scrollToMessage,
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
export function isEditableTarget(target: EventTarget | null | undefined) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (/^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName)) return true
|
||||
if (target.isContentEditable) return true
|
||||
if (target.closest("[contenteditable='true']")) return true
|
||||
if (target.closest("input, textarea, select")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
|
||||
const r = document.createRange()
|
||||
r.selectNodeContents(lineElement)
|
||||
|
||||
6
packages/app/src/utils/same.ts
Normal file
6
packages/app/src/utils/same.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
@@ -22,5 +22,6 @@
|
||||
}
|
||||
},
|
||||
"include": ["src", "package.json"],
|
||||
"exclude": ["dist", "ts-dist"]
|
||||
"exclude": ["dist", "ts-dist"],
|
||||
"references": [{ "path": "../sdk/js" }]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -108,26 +108,6 @@ const DOCS_SEGMENT = new Set([
|
||||
"zh-tw",
|
||||
])
|
||||
|
||||
const DOCS_LOCALE = {
|
||||
ar: "ar",
|
||||
da: "da",
|
||||
de: "de",
|
||||
en: "en",
|
||||
es: "es",
|
||||
fr: "fr",
|
||||
it: "it",
|
||||
ja: "ja",
|
||||
ko: "ko",
|
||||
nb: "no",
|
||||
"pt-br": "br",
|
||||
root: "en",
|
||||
ru: "ru",
|
||||
th: "th",
|
||||
tr: "tr",
|
||||
"zh-cn": "zh",
|
||||
"zh-tw": "zht",
|
||||
} as const satisfies Record<string, Locale>
|
||||
|
||||
function suffix(pathname: string) {
|
||||
const index = pathname.search(/[?#]/)
|
||||
if (index === -1) {
|
||||
@@ -150,12 +130,7 @@ export function docs(locale: Locale, pathname: string) {
|
||||
return `${next.path}${next.suffix}`
|
||||
}
|
||||
|
||||
if (value === "root") {
|
||||
if (next.path === "/docs/en") return `/docs${next.suffix}`
|
||||
if (next.path === "/docs/en/") return `/docs/${next.suffix}`
|
||||
if (next.path.startsWith("/docs/en/")) return `/docs/${next.path.slice("/docs/en/".length)}${next.suffix}`
|
||||
return `${next.path}${next.suffix}`
|
||||
}
|
||||
if (value === "root") return `${next.path}${next.suffix}`
|
||||
|
||||
if (next.path === "/docs") return `/docs/${value}${next.suffix}`
|
||||
if (next.path === "/docs/") return `/docs/${value}/${next.suffix}`
|
||||
@@ -179,15 +154,6 @@ export function fromPathname(pathname: string) {
|
||||
return parseLocale(fix(pathname).split("/")[1])
|
||||
}
|
||||
|
||||
export function fromDocsPathname(pathname: string) {
|
||||
const next = fix(pathname)
|
||||
const value = next.split("/")[2]?.toLowerCase()
|
||||
if (!value) return null
|
||||
if (!next.startsWith("/docs/")) return null
|
||||
if (!(value in DOCS_LOCALE)) return null
|
||||
return DOCS_LOCALE[value as keyof typeof DOCS_LOCALE]
|
||||
}
|
||||
|
||||
export function strip(pathname: string) {
|
||||
const locale = fromPathname(pathname)
|
||||
if (!locale) return fix(pathname)
|
||||
@@ -306,9 +272,6 @@ export function localeFromRequest(request: Request) {
|
||||
const fromPath = fromPathname(new URL(request.url).pathname)
|
||||
if (fromPath) return fromPath
|
||||
|
||||
const fromDocsPath = fromDocsPathname(new URL(request.url).pathname)
|
||||
if (fromDocsPath) return fromDocsPath
|
||||
|
||||
return (
|
||||
localeFromCookieHeader(request.headers.get("cookie")) ??
|
||||
detectFromAcceptLanguage(request.headers.get("accept-language"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
|
||||
import { docs, localeFromRequest, tag } from "~/lib/language"
|
||||
|
||||
async function handler(evt: APIEvent) {
|
||||
const req = evt.request.clone()
|
||||
@@ -17,9 +17,7 @@ async function handler(evt: APIEvent) {
|
||||
headers,
|
||||
body: req.body,
|
||||
})
|
||||
const next = new Response(response.body, response)
|
||||
next.headers.append("set-cookie", cookie(locale))
|
||||
return next
|
||||
return response
|
||||
}
|
||||
|
||||
export const GET = handler
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
|
||||
import { docs, localeFromRequest, tag } from "~/lib/language"
|
||||
|
||||
async function handler(evt: APIEvent) {
|
||||
const req = evt.request.clone()
|
||||
@@ -17,9 +17,7 @@ async function handler(evt: APIEvent) {
|
||||
headers,
|
||||
body: req.body,
|
||||
})
|
||||
const next = new Response(response.body, response)
|
||||
next.headers.append("set-cookie", cookie(locale))
|
||||
return next
|
||||
return response
|
||||
}
|
||||
|
||||
export const GET = handler
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
|
||||
import { docs, localeFromRequest, tag } from "~/lib/language"
|
||||
|
||||
async function handler(evt: APIEvent) {
|
||||
const req = evt.request.clone()
|
||||
@@ -17,9 +17,7 @@ async function handler(evt: APIEvent) {
|
||||
headers,
|
||||
body: req.body,
|
||||
})
|
||||
const next = new Response(response.body, response)
|
||||
next.headers.append("set-cookie", cookie(locale))
|
||||
return next
|
||||
return response
|
||||
}
|
||||
|
||||
export const GET = handler
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -76,7 +76,6 @@ function detectLocale(): Locale {
|
||||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||
for (const language of languages) {
|
||||
if (!language) continue
|
||||
if (language.toLowerCase().startsWith("en")) return "en"
|
||||
if (language.toLowerCase().startsWith("zh")) {
|
||||
if (language.toLowerCase().includes("hant")) return "zht"
|
||||
return "zh"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -77,7 +77,6 @@ function detectLocale(): Locale {
|
||||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||
for (const language of languages) {
|
||||
if (!language) continue
|
||||
if (language.toLowerCase().startsWith("en")) return "en"
|
||||
if (language.toLowerCase().startsWith("zh")) {
|
||||
if (language.toLowerCase().includes("hant")) return "zht"
|
||||
return "zh"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
|
||||
import { fn } from "@opencode-ai/util/fn"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { Identifier } from "@opencode-ai/util/identifier"
|
||||
import z from "zod"
|
||||
import { Storage } from "./storage"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
|
||||
export namespace Share {
|
||||
export const Info = z.object({
|
||||
@@ -36,81 +38,6 @@ export namespace Share {
|
||||
])
|
||||
export type Data = z.infer<typeof Data>
|
||||
|
||||
type Snapshot = {
|
||||
data: Data[]
|
||||
}
|
||||
|
||||
type Compaction = {
|
||||
event?: string
|
||||
data: Data[]
|
||||
}
|
||||
|
||||
function key(item: Data) {
|
||||
switch (item.type) {
|
||||
case "session":
|
||||
return "session"
|
||||
case "message":
|
||||
return `message/${item.data.id}`
|
||||
case "part":
|
||||
return `part/${item.data.messageID}/${item.data.id}`
|
||||
case "session_diff":
|
||||
return "session_diff"
|
||||
case "model":
|
||||
return "model"
|
||||
}
|
||||
}
|
||||
|
||||
function merge(...items: Data[][]) {
|
||||
const map = new Map<string, Data>()
|
||||
for (const list of items) {
|
||||
for (const item of list) {
|
||||
map.set(key(item), item)
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([, item]) => item)
|
||||
}
|
||||
|
||||
async function readSnapshot(shareID: string) {
|
||||
return (await Storage.read<Snapshot>(["share_snapshot", shareID]))?.data
|
||||
}
|
||||
|
||||
async function writeSnapshot(shareID: string, data: Data[]) {
|
||||
await Storage.write<Snapshot>(["share_snapshot", shareID], { data })
|
||||
}
|
||||
|
||||
async function legacy(shareID: string) {
|
||||
const compaction: Compaction = (await Storage.read<Compaction>(["share_compaction", shareID])) ?? {
|
||||
data: [],
|
||||
event: undefined,
|
||||
}
|
||||
const list = await Storage.list({
|
||||
prefix: ["share_event", shareID],
|
||||
before: compaction.event,
|
||||
}).then((x) => x.toReversed())
|
||||
if (list.length === 0) {
|
||||
if (compaction.data.length > 0) await writeSnapshot(shareID, compaction.data)
|
||||
return compaction.data
|
||||
}
|
||||
|
||||
const next = merge(
|
||||
compaction.data,
|
||||
await Promise.all(list.map(async (event) => await Storage.read<Data[]>(event))).then((x) =>
|
||||
x.flatMap((item) => item ?? []),
|
||||
),
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
Storage.write(["share_compaction", shareID], {
|
||||
event: list.at(-1)?.at(-1),
|
||||
data: next,
|
||||
}),
|
||||
writeSnapshot(shareID, next),
|
||||
])
|
||||
return next
|
||||
}
|
||||
|
||||
export const create = fn(z.object({ sessionID: z.string() }), async (body) => {
|
||||
const isTest = process.env.NODE_ENV === "test" || body.sessionID.startsWith("test_")
|
||||
const info: Info = {
|
||||
@@ -120,7 +47,7 @@ export namespace Share {
|
||||
}
|
||||
const exists = await get(info.id)
|
||||
if (exists) throw new Errors.AlreadyExists(info.id)
|
||||
await Promise.all([Storage.write(["share", info.id], info), writeSnapshot(info.id, [])])
|
||||
await Storage.write(["share", info.id], info)
|
||||
return info
|
||||
})
|
||||
|
||||
@@ -133,13 +60,8 @@ export namespace Share {
|
||||
if (!share) throw new Errors.NotFound(body.id)
|
||||
if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id)
|
||||
await Storage.remove(["share", body.id])
|
||||
const groups = await Promise.all([
|
||||
Storage.list({ prefix: ["share_snapshot", body.id] }),
|
||||
Storage.list({ prefix: ["share_compaction", body.id] }),
|
||||
Storage.list({ prefix: ["share_event", body.id] }),
|
||||
Storage.list({ prefix: ["share_data", body.id] }),
|
||||
])
|
||||
for (const item of groups.flat()) {
|
||||
const list = await Storage.list({ prefix: ["share_data", body.id] })
|
||||
for (const item of list) {
|
||||
await Storage.remove(item)
|
||||
}
|
||||
})
|
||||
@@ -153,13 +75,59 @@ export namespace Share {
|
||||
const share = await get(input.share.id)
|
||||
if (!share) throw new Errors.NotFound(input.share.id)
|
||||
if (share.secret !== input.share.secret) throw new Errors.InvalidSecret(input.share.id)
|
||||
const data = (await readSnapshot(input.share.id)) ?? (await legacy(input.share.id))
|
||||
await writeSnapshot(input.share.id, merge(data, input.data))
|
||||
await Storage.write(["share_event", input.share.id, Identifier.descending()], input.data)
|
||||
},
|
||||
)
|
||||
|
||||
type Compaction = {
|
||||
event?: string
|
||||
data: Data[]
|
||||
}
|
||||
|
||||
export async function data(shareID: string) {
|
||||
return (await readSnapshot(shareID)) ?? legacy(shareID)
|
||||
console.log("reading compaction")
|
||||
const compaction: Compaction = (await Storage.read<Compaction>(["share_compaction", shareID])) ?? {
|
||||
data: [],
|
||||
event: undefined,
|
||||
}
|
||||
console.log("reading pending events")
|
||||
const list = await Storage.list({
|
||||
prefix: ["share_event", shareID],
|
||||
before: compaction.event,
|
||||
}).then((x) => x.toReversed())
|
||||
|
||||
console.log("compacting", list.length)
|
||||
|
||||
if (list.length > 0) {
|
||||
const data = await Promise.all(list.map(async (event) => await Storage.read<Data[]>(event))).then((x) => x.flat())
|
||||
for (const item of data) {
|
||||
if (!item) continue
|
||||
const key = (item: Data) => {
|
||||
switch (item.type) {
|
||||
case "session":
|
||||
return "session"
|
||||
case "message":
|
||||
return `message/${item.data.id}`
|
||||
case "part":
|
||||
return `${item.data.messageID}/${item.data.id}`
|
||||
case "session_diff":
|
||||
return "session_diff"
|
||||
case "model":
|
||||
return "model"
|
||||
}
|
||||
}
|
||||
const id = key(item)
|
||||
const result = Binary.search(compaction.data, id, key)
|
||||
if (result.found) {
|
||||
compaction.data[result.index] = item
|
||||
} else {
|
||||
compaction.data.splice(result.index, 0, item)
|
||||
}
|
||||
}
|
||||
compaction.event = list.at(-1)?.at(-1)
|
||||
await Storage.write(["share_compaction", shareID], compaction)
|
||||
}
|
||||
return compaction.data
|
||||
}
|
||||
|
||||
export const syncOld = fn(
|
||||
|
||||
@@ -108,7 +108,6 @@ app
|
||||
validator("param", z.object({ shareID: z.string() })),
|
||||
async (c) => {
|
||||
const { shareID } = c.req.valid("param")
|
||||
c.header("Cache-Control", "public, max-age=30, s-maxage=300, stale-while-revalidate=86400")
|
||||
return c.json(await Share.data(shareID))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5,11 +5,12 @@ import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
|
||||
import { Share } from "~/core/share"
|
||||
import { Logo, Mark } from "@opencode-ai/ui/logo"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { createDefaultOptions } from "@opencode-ai/ui/pierre"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
@@ -19,11 +20,11 @@ import z from "zod"
|
||||
import NotFound from "../[...404]"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
||||
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { FileSSR } from "@opencode-ai/ui/file-ssr"
|
||||
import { clientOnly } from "@solidjs/start"
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { Base64 } from "js-base64"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
|
||||
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
|
||||
import("@opencode-ai/ui/pierre/worker").then((m) => ({
|
||||
@@ -53,6 +54,12 @@ const getData = query(async (shareID) => {
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiff[]
|
||||
}
|
||||
session_diff_preload: {
|
||||
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
|
||||
}
|
||||
session_diff_preload_split: {
|
||||
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
|
||||
}
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
}
|
||||
@@ -72,6 +79,12 @@ const getData = query(async (shareID) => {
|
||||
session_diff: {
|
||||
[share.sessionID]: [],
|
||||
},
|
||||
session_diff_preload: {
|
||||
[share.sessionID]: [],
|
||||
},
|
||||
session_diff_preload_split: {
|
||||
[share.sessionID]: [],
|
||||
},
|
||||
session_status: {
|
||||
[share.sessionID]: {
|
||||
type: "idle",
|
||||
@@ -88,6 +101,28 @@ const getData = query(async (shareID) => {
|
||||
break
|
||||
case "session_diff":
|
||||
result.session_diff[share.sessionID] = item.data
|
||||
await Promise.all([
|
||||
Promise.all(
|
||||
item.data.map(async (diff) =>
|
||||
preloadMultiFileDiff<any>({
|
||||
oldFile: { name: diff.file, contents: diff.before },
|
||||
newFile: { name: diff.file, contents: diff.after },
|
||||
options: createDefaultOptions("unified"),
|
||||
// annotations,
|
||||
}),
|
||||
),
|
||||
).then((r) => (result.session_diff_preload[share.sessionID] = r)),
|
||||
Promise.all(
|
||||
item.data.map(async (diff) =>
|
||||
preloadMultiFileDiff<any>({
|
||||
oldFile: { name: diff.file, contents: diff.before },
|
||||
newFile: { name: diff.file, contents: diff.after },
|
||||
options: createDefaultOptions("split"),
|
||||
// annotations,
|
||||
}),
|
||||
),
|
||||
).then((r) => (result.session_diff_preload_split[share.sessionID] = r)),
|
||||
])
|
||||
break
|
||||
case "message":
|
||||
result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? []
|
||||
@@ -108,15 +143,17 @@ const getData = query(async (shareID) => {
|
||||
}, "getShareData")
|
||||
|
||||
export default function () {
|
||||
getRequestEvent()?.response.headers.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=30, s-maxage=300, stale-while-revalidate=86400",
|
||||
)
|
||||
|
||||
const params = useParams()
|
||||
const data = createAsync(async () => {
|
||||
if (!params.shareID) throw new Error("Missing shareID")
|
||||
return getData(params.shareID)
|
||||
const now = Date.now()
|
||||
const data = getData(params.shareID)
|
||||
console.log("getData", Date.now() - now)
|
||||
return data
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
console.log(data())
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -204,8 +241,22 @@ export default function () {
|
||||
const provider = createMemo(() => activeMessage()?.model?.providerID)
|
||||
const modelID = createMemo(() => activeMessage()?.model?.modelID)
|
||||
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
|
||||
const diffs = createMemo(() => data().session_diff[data().sessionID] ?? [])
|
||||
const [diffStyle, setDiffStyle] = createSignal<"unified" | "split">("unified")
|
||||
const diffs = createMemo(() => {
|
||||
const diffs = data().session_diff[data().sessionID] ?? []
|
||||
const preloaded = data().session_diff_preload[data().sessionID] ?? []
|
||||
return diffs.map((diff) => ({
|
||||
...diff,
|
||||
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
|
||||
}))
|
||||
})
|
||||
const splitDiffs = createMemo(() => {
|
||||
const diffs = data().session_diff[data().sessionID] ?? []
|
||||
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
|
||||
return diffs.map((diff) => ({
|
||||
...diff,
|
||||
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
|
||||
}))
|
||||
})
|
||||
|
||||
const title = () => (
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -329,9 +380,18 @@ export default function () {
|
||||
<Show when={diffs().length > 0}>
|
||||
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
|
||||
<SessionReview
|
||||
class="@4xl:hidden"
|
||||
diffs={diffs()}
|
||||
diffStyle={diffStyle()}
|
||||
onDiffStyleChange={setDiffStyle}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
container: "px-6",
|
||||
}}
|
||||
/>
|
||||
<SessionReview
|
||||
split
|
||||
class="hidden @4xl:flex"
|
||||
diffs={splitDiffs()}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
@@ -359,7 +419,11 @@ export default function () {
|
||||
<Tabs.Content value="session" class="!overflow-hidden">
|
||||
{turns()}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="review" class="!overflow-hidden hidden data-[selected]:block">
|
||||
<Tabs.Content
|
||||
forceMount
|
||||
value="review"
|
||||
class="!overflow-hidden hidden data-[selected]:block"
|
||||
>
|
||||
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
|
||||
<SessionReview
|
||||
diffs={diffs()}
|
||||
|
||||
@@ -30,8 +30,8 @@ describe.concurrent("core.share", () => {
|
||||
data,
|
||||
})
|
||||
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
expect(snapshot?.data).toHaveLength(1)
|
||||
const events = await Storage.list({ prefix: ["share_event", share.id] })
|
||||
expect(events.length).toBe(1)
|
||||
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
@@ -64,8 +64,8 @@ describe.concurrent("core.share", () => {
|
||||
data: data2,
|
||||
})
|
||||
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
expect(snapshot?.data).toHaveLength(2)
|
||||
const events = await Storage.list({ prefix: ["share_event", share.id] })
|
||||
expect(events.length).toBe(2)
|
||||
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
@@ -194,28 +194,6 @@ describe.concurrent("core.share", () => {
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
|
||||
test("should migrate legacy event data into the snapshot", async () => {
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
const data: Share.Data[] = [
|
||||
{
|
||||
type: "part",
|
||||
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello" },
|
||||
},
|
||||
]
|
||||
|
||||
await Storage.remove(["share_snapshot", share.id])
|
||||
await Storage.write(["share_event", share.id, Identifier.descending()], data)
|
||||
|
||||
const result = await Share.data(share.id)
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(snapshot?.data).toHaveLength(1)
|
||||
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
|
||||
test("should throw error for invalid secret", async () => {
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.20"
|
||||
version = "1.2.17"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.17",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -43,10 +43,9 @@
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/which": "3.0.4",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
@@ -106,7 +105,7 @@
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||
"fuzzysort": "3.1.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
@@ -128,13 +127,12 @@
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
"zod-to-json-schema": "3.24.5"
|
||||
},
|
||||
"overrides": {
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6"
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { $ } from "bun"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
@@ -161,9 +161,7 @@ for (const item of targets) {
|
||||
console.log(`building ${name}`)
|
||||
await $`mkdir -p dist/${name}/bin`
|
||||
|
||||
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
|
||||
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
|
||||
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
|
||||
const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"))
|
||||
const workerPath = "./src/cli/cmd/tui/worker.ts"
|
||||
|
||||
// Use platform-specific bunfs root path based on target OS
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
import { Log } from "../util/log"
|
||||
import { pathToFileURL } from "bun"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Hash } from "../util/hash"
|
||||
import { ACPSessionManager } from "./session"
|
||||
import type { ACPConfig } from "./types"
|
||||
import { Provider } from "../provider/provider"
|
||||
@@ -282,7 +281,7 @@ export namespace ACP {
|
||||
const output = this.bashOutput(part)
|
||||
const content: ToolCallContent[] = []
|
||||
if (output) {
|
||||
const hash = Hash.fast(output)
|
||||
const hash = String(Bun.hash(output))
|
||||
if (part.tool === "bash") {
|
||||
if (this.bashSnapshots.get(part.callID) === hash) {
|
||||
await this.connection
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Instance } from "../../project/instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "../../util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||
|
||||
@@ -48,7 +47,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
const method = plugin.auth.methods[index]
|
||||
|
||||
// Handle prompts for all auth types
|
||||
await sleep(10)
|
||||
await Bun.sleep(10)
|
||||
const inputs: Record<string, string> = {}
|
||||
if (method.prompts) {
|
||||
for (const prompt of method.prompts) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { Log } from "../../../util/log"
|
||||
import { EOL } from "os"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
@@ -20,7 +19,7 @@ const DiagnosticsCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
await LSP.touchFile(args.file, true)
|
||||
await sleep(1000)
|
||||
await Bun.sleep(1000)
|
||||
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -28,7 +28,6 @@ import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { $ } from "bun"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -354,7 +353,7 @@ export const GithubInstallCommand = cmd({
|
||||
}
|
||||
|
||||
retries++
|
||||
await sleep(1000)
|
||||
await Bun.sleep(1000)
|
||||
} while (true)
|
||||
|
||||
s.stop("Installed GitHub app")
|
||||
@@ -1373,7 +1372,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
} catch (e) {
|
||||
if (retries > 0) {
|
||||
console.log(`Retrying after ${delayMs}ms...`)
|
||||
await sleep(delayMs)
|
||||
await Bun.sleep(delayMs)
|
||||
return withRetry(fn, retries - 1, delayMs)
|
||||
}
|
||||
throw e
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Filesystem } from "../../util/filesystem"
|
||||
import { Process } from "../../util/process"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { which } from "../../util/which"
|
||||
|
||||
function pagerCmd(): string[] {
|
||||
const lessOptions = ["-R", "-S"]
|
||||
@@ -18,7 +17,7 @@ function pagerCmd(): string[] {
|
||||
}
|
||||
|
||||
// user could have less installed via other options
|
||||
const lessOnPath = which("less")
|
||||
const lessOnPath = Bun.which("less")
|
||||
if (lessOnPath) {
|
||||
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
|
||||
}
|
||||
@@ -28,7 +27,7 @@ function pagerCmd(): string[] {
|
||||
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
||||
}
|
||||
|
||||
const git = which("git")
|
||||
const git = Bun.which("git")
|
||||
if (git) {
|
||||
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
|
||||
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
||||
|
||||
@@ -1625,14 +1625,11 @@ function InlineTool(props: {
|
||||
spinner?: boolean
|
||||
children: JSX.Element
|
||||
part: ToolPart
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const [margin, setMargin] = createSignal(0)
|
||||
const { theme } = useTheme()
|
||||
const ctx = use()
|
||||
const sync = useSync()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
|
||||
const permission = createMemo(() => {
|
||||
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
|
||||
@@ -1642,7 +1639,6 @@ function InlineTool(props: {
|
||||
|
||||
const fg = createMemo(() => {
|
||||
if (permission()) return theme.warning
|
||||
if (hover() && props.onClick) return theme.text
|
||||
if (props.complete) return theme.textMuted
|
||||
return theme.text
|
||||
})
|
||||
@@ -1660,12 +1656,6 @@ function InlineTool(props: {
|
||||
<box
|
||||
marginTop={margin()}
|
||||
paddingLeft={3}
|
||||
onMouseOver={() => props.onClick && setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
props.onClick?.()
|
||||
}}
|
||||
renderBefore={function () {
|
||||
const el = this as BoxRenderable
|
||||
const parent = el.parent
|
||||
@@ -2009,11 +1999,6 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
complete={props.input.description}
|
||||
pending="Delegating..."
|
||||
part={props.part}
|
||||
onClick={() => {
|
||||
if (props.metadata.sessionId) {
|
||||
navigate({ type: "session", sessionID: props.metadata.sessionId })
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content()}
|
||||
</InlineTool>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user