mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-17 22:34:25 +00:00
Compare commits
56 Commits
v1.2.1
...
test-field
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b01ed950de | ||
|
|
d93cefd47a | ||
|
|
a580fb47d2 | ||
|
|
9d3c81a683 | ||
|
|
86e545a23e | ||
|
|
b0afdf6ea4 | ||
|
|
d8c25bfeb4 | ||
|
|
160ba295a8 | ||
|
|
16332a8583 | ||
|
|
ae6e85b2a4 | ||
|
|
fdad823edc | ||
|
|
5cc1d6097e | ||
|
|
8c1af9b445 | ||
|
|
ef979ccfa8 | ||
|
|
bb30e06855 | ||
|
|
b055f973df | ||
|
|
45fa5e7199 | ||
|
|
3ebf27aab9 | ||
|
|
1d041c8861 | ||
|
|
089ab9defa | ||
|
|
f7708efa5b | ||
|
|
60807846a9 | ||
|
|
afd0716cbd | ||
|
|
920255e8c6 | ||
|
|
21e0778002 | ||
|
|
d9363da9ee | ||
|
|
9b23130ac4 | ||
|
|
62a24c2dda | ||
|
|
3a3aa300bb | ||
|
|
cf50a289db | ||
|
|
3c85cf4fac | ||
|
|
878ddc6a0a | ||
|
|
3761121728 | ||
|
|
3aaa34be1e | ||
|
|
985c2a3d15 | ||
|
|
eb553f53ac | ||
|
|
d1482e1483 | ||
|
|
45f0050372 | ||
|
|
b5c8bd3421 | ||
|
|
2bab5e8c39 | ||
|
|
85b5f5b705 | ||
|
|
460a87f359 | ||
|
|
c190f5f611 | ||
|
|
7911cb62ab | ||
|
|
839c5cda12 | ||
|
|
67c985ce82 | ||
|
|
575f2cf2a5 | ||
|
|
933a491ade | ||
|
|
3b6b3e6fc8 | ||
|
|
8631d6c01d | ||
|
|
68bb8ce1da | ||
|
|
306fc77076 | ||
|
|
759ec104b6 | ||
|
|
ef205c3660 | ||
|
|
df3203d2dd | ||
|
|
ed439b2057 |
@@ -359,6 +359,7 @@ opencode serve --hostname 0.0.0.0 --port 4096
|
||||
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
||||
opencode session [command]
|
||||
opencode session list
|
||||
opencode session delete <sessionID>
|
||||
opencode stats
|
||||
opencode uninstall
|
||||
opencode upgrade
|
||||
@@ -598,6 +599,7 @@ OPENCODE_EXPERIMENTAL_MARKDOWN
|
||||
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|
||||
OPENCODE_EXPERIMENTAL_OXFMT
|
||||
OPENCODE_EXPERIMENTAL_PLAN_MODE
|
||||
OPENCODE_ENABLE_QUESTION_TOOL
|
||||
OPENCODE_FAKE_VCS
|
||||
OPENCODE_GIT_BASH_PATH
|
||||
OPENCODE_MODEL
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
// "enterprise": {
|
||||
// "url": "https://enterprise.dev.opencode.ai",
|
||||
// },
|
||||
"provider": {
|
||||
"opencode": {
|
||||
"options": {},
|
||||
|
||||
5
.signpath/policies/opencode/test-signing.yml
Normal file
5
.signpath/policies/opencode/test-signing.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
github-policies:
|
||||
runners:
|
||||
allowed_groups:
|
||||
- "GitHub Actions"
|
||||
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
139
README.uk.md
Normal file
139
README.uk.md
Normal file
@@ -0,0 +1,139 @@
|
||||
<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">AI-агент для програмування з відкритим кодом.</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>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
---
|
||||
|
||||
### Встановлення
|
||||
|
||||
```bash
|
||||
# YOLO
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Менеджери пакетів
|
||||
npm i -g opencode-ai@latest # або bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS і Linux (рекомендовано, завжди актуально)
|
||||
brew install opencode # macOS і Linux (офіційна формула Homebrew, оновлюється рідше)
|
||||
sudo pacman -S opencode # Arch Linux (Stable)
|
||||
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||
mise use -g opencode # Будь-яка ОС
|
||||
nix run nixpkgs#opencode # або github:anomalyco/opencode для найновішої dev-гілки
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Перед встановленням видаліть версії старші за 0.1.x.
|
||||
|
||||
### Десктопний застосунок (BETA)
|
||||
|
||||
OpenCode також доступний як десктопний застосунок. Завантажуйте напряму зі [сторінки релізів](https://github.com/anomalyco/opencode/releases) або [opencode.ai/download](https://opencode.ai/download).
|
||||
|
||||
| Платформа | Завантаження |
|
||||
| --------------------- | ------------------------------------- |
|
||||
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||
| Linux | `.deb`, `.rpm` або AppImage |
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask opencode-desktop
|
||||
# Windows (Scoop)
|
||||
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||
```
|
||||
|
||||
#### Каталог встановлення
|
||||
|
||||
Скрипт встановлення дотримується такого порядку пріоритету для шляху встановлення:
|
||||
|
||||
1. `$OPENCODE_INSTALL_DIR` - Користувацький каталог встановлення
|
||||
2. `$XDG_BIN_DIR` - Шлях, сумісний зі специфікацією XDG Base Directory
|
||||
3. `$HOME/bin` - Стандартний каталог користувацьких бінарників (якщо існує або його можна створити)
|
||||
4. `$HOME/.opencode/bin` - Резервний варіант за замовчуванням
|
||||
|
||||
```bash
|
||||
# Приклади
|
||||
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
### Агенти
|
||||
|
||||
OpenCode містить два вбудовані агенти, між якими можна перемикатися клавішею `Tab`.
|
||||
|
||||
- **build** - Агент за замовчуванням із повним доступом для завдань розробки
|
||||
- **plan** - Агент лише для читання для аналізу та дослідження коду
|
||||
- За замовчуванням забороняє редагування файлів
|
||||
- Запитує дозвіл перед запуском bash-команд
|
||||
- Ідеально підходить для дослідження незнайомих кодових баз або планування змін
|
||||
|
||||
Також доступний допоміжний агент **general** для складного пошуку та багатокрокових завдань.
|
||||
Він використовується всередині системи й може бути викликаний у повідомленнях через `@general`.
|
||||
|
||||
Дізнайтеся більше про [agents](https://opencode.ai/docs/agents).
|
||||
|
||||
### Документація
|
||||
|
||||
Щоб дізнатися більше про налаштування OpenCode, [**перейдіть до нашої документації**](https://opencode.ai/docs).
|
||||
|
||||
### Внесок
|
||||
|
||||
Якщо ви хочете зробити внесок в OpenCode, будь ласка, прочитайте нашу [документацію для контриб'юторів](./CONTRIBUTING.md) перед надсиланням pull request.
|
||||
|
||||
### Проєкти на базі OpenCode
|
||||
|
||||
Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README.
|
||||
Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Чим це відрізняється від Claude Code?
|
||||
|
||||
За можливостями це дуже схоже на Claude Code. Ось ключові відмінності:
|
||||
|
||||
- 100% open source
|
||||
- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [OpenCode Zen](https://opencode.ai/zen), але OpenCode також працює з Claude, OpenAI, Google і навіть локальними моделями. З розвитком моделей різниця між ними зменшуватиметься, а ціни падатимуть, тому незалежність від провайдера має значення.
|
||||
- Підтримка LSP з коробки
|
||||
- Фокус на TUI. OpenCode створено користувачами neovim та авторами [terminal.shop](https://terminal.shop); ми й надалі розширюватимемо межі можливого в терміналі.
|
||||
- Клієнт-серверна архітектура. Наприклад, це дає змогу запускати OpenCode на вашому комп'ютері й керувати ним віддалено з мобільного застосунку, тобто TUI-фронтенд - лише один із можливих клієнтів.
|
||||
|
||||
---
|
||||
|
||||
**Приєднуйтеся до нашої спільноти** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
49
bun.lock
49
bun.lock
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -215,7 +215,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -244,7 +244,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -260,7 +260,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -276,7 +276,7 @@
|
||||
"@ai-sdk/deepinfra": "1.0.36",
|
||||
"@ai-sdk/gateway": "2.0.30",
|
||||
"@ai-sdk/google": "2.0.52",
|
||||
"@ai-sdk/google-vertex": "3.0.98",
|
||||
"@ai-sdk/google-vertex": "3.0.103",
|
||||
"@ai-sdk/groq": "2.0.34",
|
||||
"@ai-sdk/mistral": "2.0.27",
|
||||
"@ai-sdk/openai": "2.0.89",
|
||||
@@ -288,8 +288,8 @@
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.5.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.2",
|
||||
"@gitlab/gitlab-ai-provider": "3.5.1",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
@@ -369,7 +369,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -389,7 +389,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -400,7 +400,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -413,7 +413,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -455,7 +455,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -466,7 +466,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -504,6 +504,7 @@
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
},
|
||||
"overrides": {
|
||||
@@ -594,7 +595,7 @@
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
|
||||
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.98", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uuv0RHkdJ5vTzeH1+iuBlv7GAjRcOPd2jiqtGLz6IKOUDH+PRQoE3ExrvOysVnKuhhTBMqvawkktDhMDQE6sVQ=="],
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.103", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.63", "@ai-sdk/google": "2.0.53", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MPZRSVOJFxYGHE4s6XjSWaiUPru7u2i/LUUA1Ih2nzNYZaei8c46Z56imOCD/KQjQX3afRA2iZh6P5McsmwhqA=="],
|
||||
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
|
||||
|
||||
@@ -988,9 +989,9 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.5.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-OoAwCz4fOci3h/2l+PRHMclclh3IaFq8w1es2wvBJ8ca7vtglKsBYT7dvmYpsXlu7pg9mopbjcexvmVCQEUTAQ=="],
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.5.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-I8+EGdUeKmGJSjAdFobHtqpxM9Fm00w0j7NJbtln/D/XQ1SKEGoZIuqJko4v0pV2mkhGUIs7qezljH/2kbXovA=="],
|
||||
|
||||
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.2", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-pvGrC+aDVLY8bRCC/fZaG/Qihvt2r4by5xbTo5JTSz9O7yIcR6xG2d9Wkuu4bcXFz674z2C+i5bUk+J/RSdBpg=="],
|
||||
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
@@ -4208,7 +4209,11 @@
|
||||
|
||||
"@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "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-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.63", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zXlUPCkumnvp8lWS9VFcen/MLF6CL/t1zAKDhpobYj9y/nmylQrKtRvn3RwH871Wd3dF3KYEUXd6M2c6dfCKOA=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ccCxr5mrd3AC2CjLq4e1ST7+UiN5T2Pdmgi0XdWM3QohmNBwUQ/RBG7BvL+cB/ex/j6y64tkMmpYz9zBw/SEFQ=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "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-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770073757,
|
||||
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
|
||||
"lastModified": 1770812194,
|
||||
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
|
||||
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-hVf8rBEqy3q4xexOqyKDtKmlMydl1hFoDV0JiEvmfgs=",
|
||||
"aarch64-linux": "sha256-4m3UZllEmfJXB70cOgIoyWRIYMXxGzzenyOfF3kEQKk=",
|
||||
"aarch64-darwin": "sha256-27xGR9+FVnC0rsUIyepk2tCP1eEUmGvqWUGAZ+rk7IQ=",
|
||||
"x86_64-darwin": "sha256-+At7bHSeg6QJu6yGawyvzt53Tu/fddDg6Ms+xhaMLhY="
|
||||
"x86_64-linux": "sha256-C3WIEER2XgzO85wk2sp3BzQ6dknW026zslD8nKZjo2U=",
|
||||
"aarch64-linux": "sha256-+tTJHZMZ/+8fAjI/1fUTuca8J2MZfB+5vhBoZ7jgqcE=",
|
||||
"aarch64-darwin": "sha256-vS82puFGBBToxyIBa8Zi0KLKdJYr64T6HZL2rL32mH8=",
|
||||
"x86_64-darwin": "sha256-Tr8JMTCxV6WVt3dXV7iq3PNCm2Cn+RXAbU9+o7pKKV0="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@
|
||||
"url": "https://github.com/anomalyco/opencode"
|
||||
},
|
||||
"license": "MIT",
|
||||
"randomField": "hello-world-12345",
|
||||
"anotherRandomField": "xyz-abc-789",
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 120
|
||||
@@ -103,6 +105,7 @@
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
|
||||
|
||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||
|
||||
export const projectClearNotificationsSelector = (slug: string) =>
|
||||
`[data-action="project-clear-notifications"][data-project="${slug}"]`
|
||||
|
||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
function pathToFileUrl(filepath: string): string {
|
||||
return `file://${encodeFilePath(filepath)}`
|
||||
}
|
||||
@@ -260,12 +262,20 @@ export default function FileTree(props: {
|
||||
_marks?: Set<string>
|
||||
_deeps?: Map<string, number>
|
||||
_kinds?: ReadonlyMap<string, Kind>
|
||||
_chain?: readonly string[]
|
||||
}) {
|
||||
const file = useFile()
|
||||
const level = props.level ?? 0
|
||||
const draggable = () => props.draggable ?? true
|
||||
const tooltip = () => props.tooltip ?? true
|
||||
|
||||
const key = (p: string) =>
|
||||
file
|
||||
.normalize(p)
|
||||
.replace(/[\\/]+$/, "")
|
||||
.replaceAll("\\", "/")
|
||||
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
|
||||
|
||||
const filter = createMemo(() => {
|
||||
if (props._filter) return props._filter
|
||||
|
||||
@@ -307,23 +317,45 @@ export default function FileTree(props: {
|
||||
|
||||
const out = new Map<string, number>()
|
||||
|
||||
const visit = (dir: string, lvl: number): number => {
|
||||
const expanded = file.tree.state(dir)?.expanded ?? false
|
||||
if (!expanded) return -1
|
||||
const root = props.path
|
||||
if (!(file.tree.state(root)?.expanded ?? false)) return out
|
||||
|
||||
const nodes = file.tree.children(dir)
|
||||
const max = nodes.reduce((max, node) => {
|
||||
if (node.type !== "directory") return max
|
||||
const open = file.tree.state(node.path)?.expanded ?? false
|
||||
if (!open) return max
|
||||
return Math.max(max, visit(node.path, lvl + 1))
|
||||
}, lvl)
|
||||
const seen = new Set<string>()
|
||||
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
|
||||
|
||||
out.set(dir, max)
|
||||
return max
|
||||
const push = (dir: string, lvl: number) => {
|
||||
const id = key(dir)
|
||||
if (seen.has(id)) return
|
||||
seen.add(id)
|
||||
|
||||
const kids = file.tree
|
||||
.children(dir)
|
||||
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
|
||||
.map((node) => node.path)
|
||||
|
||||
stack.push({ dir, lvl, i: 0, kids, max: lvl })
|
||||
}
|
||||
|
||||
push(root, level - 1)
|
||||
|
||||
while (stack.length > 0) {
|
||||
const top = stack[stack.length - 1]!
|
||||
|
||||
if (top.i < top.kids.length) {
|
||||
const next = top.kids[top.i]!
|
||||
top.i++
|
||||
push(next, top.lvl + 1)
|
||||
continue
|
||||
}
|
||||
|
||||
out.set(top.dir, top.max)
|
||||
stack.pop()
|
||||
|
||||
const parent = stack[stack.length - 1]
|
||||
if (!parent) continue
|
||||
parent.max = Math.max(parent.max, top.max)
|
||||
}
|
||||
|
||||
visit(props.path, level - 1)
|
||||
return out
|
||||
})
|
||||
|
||||
@@ -459,21 +491,27 @@ export default function FileTree(props: {
|
||||
}}
|
||||
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
|
||||
/>
|
||||
<FileTree
|
||||
path={node.path}
|
||||
level={level + 1}
|
||||
allowed={props.allowed}
|
||||
modified={props.modified}
|
||||
kinds={props.kinds}
|
||||
active={props.active}
|
||||
draggable={props.draggable}
|
||||
tooltip={props.tooltip}
|
||||
onFileClick={props.onFileClick}
|
||||
_filter={filter()}
|
||||
_marks={marks()}
|
||||
_deeps={deeps()}
|
||||
_kinds={kinds()}
|
||||
/>
|
||||
<Show
|
||||
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
|
||||
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
|
||||
>
|
||||
<FileTree
|
||||
path={node.path}
|
||||
level={level + 1}
|
||||
allowed={props.allowed}
|
||||
modified={props.modified}
|
||||
kinds={props.kinds}
|
||||
active={props.active}
|
||||
draggable={props.draggable}
|
||||
tooltip={props.tooltip}
|
||||
onFileClick={props.onFileClick}
|
||||
_filter={filter()}
|
||||
_marks={marks()}
|
||||
_deeps={deeps()}
|
||||
_kinds={kinds()}
|
||||
_chain={chain}
|
||||
/>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
|
||||
@@ -911,31 +911,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (!collapsed) return
|
||||
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
const textLength = promptLength(prompt.current())
|
||||
const textContent = prompt
|
||||
.current()
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
const direction = event.key === "ArrowUp" ? "up" : "down"
|
||||
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return
|
||||
const isEmpty = textContent.trim() === "" || textLength <= 1
|
||||
const hasNewlines = textContent.includes("\n")
|
||||
const inHistory = store.historyIndex >= 0
|
||||
const atStart = cursorPosition <= (isEmpty ? 1 : 0)
|
||||
const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength)
|
||||
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
|
||||
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
|
||||
|
||||
if (direction === "up") {
|
||||
if (!allowUp) return
|
||||
if (navigateHistory("up")) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!allowDown) return
|
||||
if (navigateHistory("down")) {
|
||||
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return
|
||||
if (navigateHistory(direction)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("prompt-input history", () => {
|
||||
expect(original[1].selection?.startLine).toBe(1)
|
||||
})
|
||||
|
||||
test("canNavigateHistoryAtCursor only allows multiline boundaries", () => {
|
||||
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
||||
const value = "a\nb\nc"
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
||||
@@ -85,7 +85,16 @@ describe("prompt-input history", () => {
|
||||
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 3, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 1, true)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 1, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
export const MAX_HISTORY = 100
|
||||
|
||||
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) {
|
||||
if (!text.includes("\n")) return true
|
||||
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
||||
const position = Math.max(0, Math.min(cursor, text.length))
|
||||
if (direction === "up") return !text.slice(0, position).includes("\n")
|
||||
return !text.slice(position).includes("\n")
|
||||
const atStart = position === 0
|
||||
const atEnd = position === text.length
|
||||
if (inHistory) return atStart || atEnd
|
||||
if (direction === "up") return position === 0
|
||||
return position === text.length
|
||||
}
|
||||
|
||||
export function clonePromptParts(prompt: Prompt): Prompt {
|
||||
|
||||
@@ -128,6 +128,7 @@ export const SettingsGeneral: Component = () => {
|
||||
{ value: "roboto-mono", label: "font.option.robotoMono" },
|
||||
{ value: "source-code-pro", label: "font.option.sourceCodePro" },
|
||||
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" },
|
||||
{ value: "geist-mono", label: "font.option.geistMono" },
|
||||
] as const
|
||||
const fontOptionsList = [...fontOptions]
|
||||
|
||||
|
||||
@@ -316,8 +316,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const isPalette = palette().has(sig)
|
||||
const option = keymap().get(sig)
|
||||
const modified = event.ctrlKey || event.metaKey || event.altKey
|
||||
const isTab = event.key === "Tab"
|
||||
|
||||
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified) return
|
||||
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab)
|
||||
return
|
||||
|
||||
if (isPalette) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -85,6 +85,7 @@ const monoFonts: Record<string, string> = {
|
||||
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
}
|
||||
|
||||
export function monoFontFamily(font: string | undefined) {
|
||||
|
||||
@@ -509,6 +509,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
|
||||
"sidebar.project.recentSessions": "الجلسات الحديثة",
|
||||
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
|
||||
"sidebar.project.clearNotifications": "مسح الإشعارات",
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "سطح المكتب",
|
||||
"settings.section.server": "الخادم",
|
||||
@@ -556,6 +557,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "تنبيه 01",
|
||||
"sound.option.alert02": "تنبيه 02",
|
||||
"sound.option.alert03": "تنبيه 03",
|
||||
|
||||
@@ -515,6 +515,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "Conecte qualquer provedor para usar modelos, incluindo Claude, GPT, Gemini etc.",
|
||||
"sidebar.project.recentSessions": "Sessões recentes",
|
||||
"sidebar.project.viewAllSessions": "Ver todas as sessões",
|
||||
"sidebar.project.clearNotifications": "Limpar notificações",
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "Desktop",
|
||||
"settings.section.server": "Servidor",
|
||||
@@ -562,6 +563,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "Alerta 01",
|
||||
"sound.option.alert02": "Alerta 02",
|
||||
"sound.option.alert03": "Alerta 03",
|
||||
|
||||
@@ -576,6 +576,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "Poveži bilo kojeg provajdera da koristiš modele, npr. Claude, GPT, Gemini itd.",
|
||||
"sidebar.project.recentSessions": "Nedavne sesije",
|
||||
"sidebar.project.viewAllSessions": "Prikaži sve sesije",
|
||||
"sidebar.project.clearNotifications": "Očisti obavijesti",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
|
||||
@@ -630,6 +631,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "Upozorenje 01",
|
||||
"sound.option.alert02": "Upozorenje 02",
|
||||
"sound.option.alert03": "Upozorenje 03",
|
||||
|
||||
@@ -572,6 +572,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "Forbind enhver udbyder for at bruge modeller, inkl. Claude, GPT, Gemini osv.",
|
||||
"sidebar.project.recentSessions": "Seneste sessioner",
|
||||
"sidebar.project.viewAllSessions": "Vis alle sessioner",
|
||||
"sidebar.project.clearNotifications": "Ryd notifikationer",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "Desktop",
|
||||
@@ -626,6 +627,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "Alarm 01",
|
||||
"sound.option.alert02": "Alarm 02",
|
||||
"sound.option.alert03": "Alarm 03",
|
||||
|
||||
@@ -524,6 +524,7 @@ export const dict = {
|
||||
"Verbinden Sie einen beliebigen Anbieter, um Modelle wie Claude, GPT, Gemini usw. zu nutzen.",
|
||||
"sidebar.project.recentSessions": "Letzte Sitzungen",
|
||||
"sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen",
|
||||
"sidebar.project.clearNotifications": "Benachrichtigungen löschen",
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "Desktop",
|
||||
"settings.section.server": "Server",
|
||||
@@ -571,6 +572,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "Alarm 01",
|
||||
"sound.option.alert02": "Alarm 02",
|
||||
"sound.option.alert03": "Alarm 03",
|
||||
|
||||
@@ -577,6 +577,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "Connect any provider to use models, inc. Claude, GPT, Gemini etc.",
|
||||
"sidebar.project.recentSessions": "Recent sessions",
|
||||
"sidebar.project.viewAllSessions": "View all sessions",
|
||||
"sidebar.project.clearNotifications": "Clear notifications",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
|
||||
@@ -631,6 +632,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
"sound.option.alert03": "Alert 03",
|
||||
|
||||
@@ -579,6 +579,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "Conecta cualquier proveedor para usar modelos, inc. Claude, GPT, Gemini etc.",
|
||||
"sidebar.project.recentSessions": "Sesiones recientes",
|
||||
"sidebar.project.viewAllSessions": "Ver todas las sesiones",
|
||||
"sidebar.project.clearNotifications": "Borrar notificaciones",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
|
||||
@@ -634,6 +635,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "Alerta 01",
|
||||
"sound.option.alert02": "Alerta 02",
|
||||
"sound.option.alert03": "Alerta 03",
|
||||
|
||||
@@ -523,6 +523,7 @@ export const dict = {
|
||||
"Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.",
|
||||
"sidebar.project.recentSessions": "Sessions récentes",
|
||||
"sidebar.project.viewAllSessions": "Voir toutes les sessions",
|
||||
"sidebar.project.clearNotifications": "Effacer les notifications",
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "Bureau",
|
||||
"settings.section.server": "Serveur",
|
||||
@@ -570,6 +571,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "Alerte 01",
|
||||
"sound.option.alert02": "Alerte 02",
|
||||
"sound.option.alert03": "Alerte 03",
|
||||
|
||||
@@ -513,6 +513,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "プロバイダーを接続して、Claude、GPT、Geminiなどのモデルを使用できます。",
|
||||
"sidebar.project.recentSessions": "最近のセッション",
|
||||
"sidebar.project.viewAllSessions": "すべてのセッションを表示",
|
||||
"sidebar.project.clearNotifications": "通知をクリア",
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "デスクトップ",
|
||||
"settings.section.server": "サーバー",
|
||||
@@ -560,6 +561,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "アラート 01",
|
||||
"sound.option.alert02": "アラート 02",
|
||||
"sound.option.alert03": "アラート 03",
|
||||
|
||||
@@ -514,6 +514,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "Claude, GPT, Gemini 등을 포함한 모델을 사용하려면 공급자를 연결하세요.",
|
||||
"sidebar.project.recentSessions": "최근 세션",
|
||||
"sidebar.project.viewAllSessions": "모든 세션 보기",
|
||||
"sidebar.project.clearNotifications": "알림 지우기",
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "데스크톱",
|
||||
"settings.section.server": "서버",
|
||||
@@ -561,6 +562,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "알림 01",
|
||||
"sound.option.alert02": "알림 02",
|
||||
"sound.option.alert03": "알림 03",
|
||||
|
||||
@@ -579,6 +579,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "Koble til en leverandør for å bruke modeller, inkl. Claude, GPT, Gemini osv.",
|
||||
"sidebar.project.recentSessions": "Nylige sesjoner",
|
||||
"sidebar.project.viewAllSessions": "Vis alle sesjoner",
|
||||
"sidebar.project.clearNotifications": "Fjern varsler",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
|
||||
@@ -633,6 +634,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "Varsel 01",
|
||||
"sound.option.alert02": "Varsel 02",
|
||||
"sound.option.alert03": "Varsel 03",
|
||||
|
||||
@@ -514,6 +514,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "Połącz dowolnego dostawcę, aby używać modeli, w tym Claude, GPT, Gemini itp.",
|
||||
"sidebar.project.recentSessions": "Ostatnie sesje",
|
||||
"sidebar.project.viewAllSessions": "Zobacz wszystkie sesje",
|
||||
"sidebar.project.clearNotifications": "Wyczyść powiadomienia",
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "Pulpit",
|
||||
"settings.section.server": "Serwer",
|
||||
@@ -561,6 +562,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
"sound.option.alert03": "Alert 03",
|
||||
|
||||
@@ -578,6 +578,7 @@ export const dict = {
|
||||
"Подключите любого провайдера для использования моделей, включая Claude, GPT, Gemini и др.",
|
||||
"sidebar.project.recentSessions": "Недавние сессии",
|
||||
"sidebar.project.viewAllSessions": "Посмотреть все сессии",
|
||||
"sidebar.project.clearNotifications": "Очистить уведомления",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "Приложение",
|
||||
@@ -631,6 +632,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
"sound.option.alert03": "Alert 03",
|
||||
|
||||
@@ -571,6 +571,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "เชื่อมต่อผู้ให้บริการใด ๆ เพื่อใช้โมเดล รวมถึง Claude, GPT, Gemini ฯลฯ",
|
||||
"sidebar.project.recentSessions": "เซสชันล่าสุด",
|
||||
"sidebar.project.viewAllSessions": "ดูเซสชันทั้งหมด",
|
||||
"sidebar.project.clearNotifications": "ล้างการแจ้งเตือน",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
|
||||
@@ -625,6 +626,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "เสียงเตือน 01",
|
||||
"sound.option.alert02": "เสียงเตือน 02",
|
||||
"sound.option.alert03": "เสียงเตือน 03",
|
||||
|
||||
@@ -569,6 +569,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "连接任意提供商即可使用更多模型,如 Claude、GPT、Gemini 等。",
|
||||
"sidebar.project.recentSessions": "最近会话",
|
||||
"sidebar.project.viewAllSessions": "查看全部会话",
|
||||
"sidebar.project.clearNotifications": "清除通知",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
|
||||
@@ -622,6 +623,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
|
||||
"sound.option.alert01": "警报 01",
|
||||
"sound.option.alert02": "警报 02",
|
||||
|
||||
@@ -567,6 +567,7 @@ export const dict = {
|
||||
"sidebar.gettingStarted.line2": "連線任意提供者即可使用更多模型,如 Claude、GPT、Gemini 等。",
|
||||
"sidebar.project.recentSessions": "最近工作階段",
|
||||
"sidebar.project.viewAllSessions": "查看全部工作階段",
|
||||
"sidebar.project.clearNotifications": "清除通知",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
"settings.section.desktop": "桌面",
|
||||
@@ -620,6 +621,7 @@ export const dict = {
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.alert01": "警報 01",
|
||||
"sound.option.alert02": "警報 02",
|
||||
"sound.option.alert03": "警報 03",
|
||||
|
||||
@@ -1692,6 +1692,13 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
const projectId = createMemo(() => panelProps.project?.id ?? "")
|
||||
const workspaces = createMemo(() => workspaceIds(panelProps.project))
|
||||
const unseenCount = createMemo(() =>
|
||||
workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
||||
)
|
||||
const clearNotifications = () =>
|
||||
workspaces()
|
||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => notification.project.markViewed(directory))
|
||||
const workspacesEnabled = createMemo(() => {
|
||||
const project = panelProps.project
|
||||
if (!project) return false
|
||||
@@ -1769,6 +1776,16 @@ export default function Layout(props: ParentProps) {
|
||||
: language.t("sidebar.workspaces.enable")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
data-action="project-clear-notifications"
|
||||
data-project={base64Encode(p().worktree)}
|
||||
disabled={unseenCount() === 0}
|
||||
onSelect={clearNotifications}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("sidebar.project.clearNotifications")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
data-action="project-close-menu"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createSortable } from "@thisbeyond/solid-dnd"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
||||
import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
|
||||
import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
|
||||
@@ -59,6 +60,7 @@ const ProjectTile = (props: {
|
||||
selected: Accessor<boolean>
|
||||
active: Accessor<boolean>
|
||||
overlay: Accessor<boolean>
|
||||
dirs: Accessor<string[]>
|
||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||
onProjectMouseLeave: (worktree: string) => void
|
||||
onProjectFocus: (worktree: string) => void
|
||||
@@ -70,73 +72,94 @@ const ProjectTile = (props: {
|
||||
setMenu: (value: boolean) => void
|
||||
setOpen: (value: boolean) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}): JSX.Element => (
|
||||
<ContextMenu
|
||||
modal={!props.sidebarHovering()}
|
||||
onOpenChange={(value) => {
|
||||
props.setMenu(value)
|
||||
if (value) props.setOpen(false)
|
||||
}}
|
||||
>
|
||||
<ContextMenu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
aria-label={displayName(props.project)}
|
||||
data-action="project-switch"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
classList={{
|
||||
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
|
||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||
!props.selected() && !props.active(),
|
||||
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||
}): JSX.Element => {
|
||||
const notification = useNotification()
|
||||
const unseenCount = createMemo(() =>
|
||||
props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
||||
)
|
||||
|
||||
const clear = () =>
|
||||
props
|
||||
.dirs()
|
||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => notification.project.markViewed(directory))
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
modal={!props.sidebarHovering()}
|
||||
onOpenChange={(value) => {
|
||||
props.setMenu(value)
|
||||
if (value) props.setOpen(false)
|
||||
}}
|
||||
onMouseEnter={(event: MouseEvent) => {
|
||||
if (!props.overlay()) return
|
||||
props.onProjectMouseEnter(props.project.worktree, event)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!props.overlay()) return
|
||||
props.onProjectMouseLeave(props.project.worktree)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!props.overlay()) return
|
||||
props.onProjectFocus(props.project.worktree)
|
||||
}}
|
||||
onClick={() => props.navigateToProject(props.project.worktree)}
|
||||
onBlur={() => props.setOpen(false)}
|
||||
>
|
||||
<ProjectIcon project={props.project} notify />
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
||||
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
data-action="project-workspaces-toggle"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
|
||||
onSelect={() => props.toggleProjectWorkspaces(props.project)}
|
||||
>
|
||||
<ContextMenu.ItemLabel>
|
||||
{props.workspacesEnabled(props.project)
|
||||
? props.language.t("sidebar.workspaces.disable")
|
||||
: props.language.t("sidebar.workspaces.enable")}
|
||||
</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item
|
||||
data-action="project-close-menu"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
onSelect={() => props.closeProject(props.project.worktree)}
|
||||
>
|
||||
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
)
|
||||
<ContextMenu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
aria-label={displayName(props.project)}
|
||||
data-action="project-switch"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
classList={{
|
||||
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
|
||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||
!props.selected() && !props.active(),
|
||||
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||
}}
|
||||
onMouseEnter={(event: MouseEvent) => {
|
||||
if (!props.overlay()) return
|
||||
props.onProjectMouseEnter(props.project.worktree, event)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!props.overlay()) return
|
||||
props.onProjectMouseLeave(props.project.worktree)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!props.overlay()) return
|
||||
props.onProjectFocus(props.project.worktree)
|
||||
}}
|
||||
onClick={() => props.navigateToProject(props.project.worktree)}
|
||||
onBlur={() => props.setOpen(false)}
|
||||
>
|
||||
<ProjectIcon project={props.project} notify />
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
||||
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
data-action="project-workspaces-toggle"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
|
||||
onSelect={() => props.toggleProjectWorkspaces(props.project)}
|
||||
>
|
||||
<ContextMenu.ItemLabel>
|
||||
{props.workspacesEnabled(props.project)
|
||||
? props.language.t("sidebar.workspaces.disable")
|
||||
: props.language.t("sidebar.workspaces.enable")}
|
||||
</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
data-action="project-clear-notifications"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
disabled={unseenCount() === 0}
|
||||
onSelect={clear}
|
||||
>
|
||||
<ContextMenu.ItemLabel>{props.language.t("sidebar.project.clearNotifications")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item
|
||||
data-action="project-close-menu"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
onSelect={() => props.closeProject(props.project.worktree)}
|
||||
>
|
||||
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const ProjectPreviewPanel = (props: {
|
||||
project: LocalProject
|
||||
@@ -254,6 +277,7 @@ export const SortableProject = (props: {
|
||||
)
|
||||
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [menu, setMenu] = createSignal(false)
|
||||
|
||||
@@ -304,6 +328,7 @@ export const SortableProject = (props: {
|
||||
selected={selected}
|
||||
active={active}
|
||||
overlay={overlay}
|
||||
dirs={dirs}
|
||||
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
|
||||
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
|
||||
onProjectFocus={props.ctx.onProjectFocus}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { useSync } from "@/context/sync"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum, base64Encode } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import FileTree from "@/components/file-tree"
|
||||
@@ -35,7 +34,6 @@ import { useSDK } from "@/context/sdk"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
|
||||
import { navMark, navParams } from "@/utils/perf"
|
||||
@@ -101,7 +99,6 @@ export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
const comments = useComments()
|
||||
const permission = usePermission()
|
||||
|
||||
const permRequest = createMemo(() => {
|
||||
const sessionID = params.id
|
||||
@@ -272,6 +269,7 @@ export default function Page() {
|
||||
if (!path) return
|
||||
file.load(path)
|
||||
openReviewPanel()
|
||||
tabs().setActive(next)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -769,11 +767,6 @@ export default function Page() {
|
||||
return lines.slice(0, 2).join("\n")
|
||||
}
|
||||
|
||||
const addSelectionToContext = (path: string, selection: FileSelection) => {
|
||||
const preview = selectionPreview(path, selection)
|
||||
prompt.context.add({ type: "file", path, selection, preview })
|
||||
}
|
||||
|
||||
const addCommentToContext = (input: {
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
@@ -912,31 +905,11 @@ export default function Page() {
|
||||
const focusInput = () => inputRef?.focus()
|
||||
|
||||
useSessionCommands({
|
||||
command,
|
||||
dialog,
|
||||
file,
|
||||
language,
|
||||
local,
|
||||
permission,
|
||||
prompt,
|
||||
sdk,
|
||||
sync,
|
||||
terminal,
|
||||
layout,
|
||||
params,
|
||||
navigate,
|
||||
tabs,
|
||||
view,
|
||||
info,
|
||||
status,
|
||||
userMessages,
|
||||
visibleUserMessages,
|
||||
activeMessage,
|
||||
showAllFiles,
|
||||
navigateMessageByOffset,
|
||||
setExpanded: (id, fn) => setStore("expanded", id, fn),
|
||||
setActiveMessage,
|
||||
addSelectionToContext,
|
||||
focusInput,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { combineCommandSections, createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "./helpers"
|
||||
import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "./helpers"
|
||||
|
||||
describe("createOpenReviewFile", () => {
|
||||
test("opens and loads selected review file", () => {
|
||||
@@ -46,20 +46,6 @@ describe("focusTerminalById", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("combineCommandSections", () => {
|
||||
test("keeps section order stable", () => {
|
||||
const result = combineCommandSections([
|
||||
[{ id: "a", title: "A" }],
|
||||
[
|
||||
{ id: "b", title: "B" },
|
||||
{ id: "c", title: "C" },
|
||||
],
|
||||
])
|
||||
|
||||
expect(result.map((item) => item.id)).toEqual(["a", "b", "c"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTabReorderIndex", () => {
|
||||
test("returns target index for valid drag reorder", () => {
|
||||
expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { CommandOption } from "@/context/command"
|
||||
import { batch } from "solid-js"
|
||||
|
||||
export const focusTerminalById = (id: string) => {
|
||||
@@ -36,10 +35,6 @@ export const createOpenReviewFile = (input: {
|
||||
}
|
||||
}
|
||||
|
||||
export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => {
|
||||
return sections.flatMap((section) => section)
|
||||
}
|
||||
|
||||
export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => {
|
||||
const fromIndex = tabs.indexOf(from)
|
||||
const toIndex = tabs.indexOf(to)
|
||||
|
||||
@@ -19,35 +19,14 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { combineCommandSections } from "@/pages/session/helpers"
|
||||
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
command: ReturnType<typeof useCommand>
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
file: ReturnType<typeof useFile>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
local: ReturnType<typeof useLocal>
|
||||
permission: ReturnType<typeof usePermission>
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
terminal: ReturnType<typeof useTerminal>
|
||||
layout: ReturnType<typeof useLayout>
|
||||
params: ReturnType<typeof useParams>
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
|
||||
status: () => { type: string }
|
||||
userMessages: () => UserMessage[]
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
activeMessage: () => UserMessage | undefined
|
||||
showAllFiles: () => void
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
addSelectionToContext: (path: string, selection: FileSelection) => void
|
||||
focusInput: () => void
|
||||
}
|
||||
|
||||
@@ -58,45 +37,88 @@ const withCategory = (category: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const sessionCommand = withCategory(input.language.t("command.category.session"))
|
||||
const fileCommand = withCategory(input.language.t("command.category.file"))
|
||||
const contextCommand = withCategory(input.language.t("command.category.context"))
|
||||
const viewCommand = withCategory(input.language.t("command.category.view"))
|
||||
const terminalCommand = withCategory(input.language.t("command.category.terminal"))
|
||||
const modelCommand = withCategory(input.language.t("command.category.model"))
|
||||
const mcpCommand = withCategory(input.language.t("command.category.mcp"))
|
||||
const agentCommand = withCategory(input.language.t("command.category.agent"))
|
||||
const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
|
||||
export const useSessionCommands = (args: SessionCommandContext) => {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const idle = { type: "idle" as const }
|
||||
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
|
||||
const visibleUserMessages = createMemo(() => {
|
||||
const revert = info()?.revert?.messageID
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
})
|
||||
|
||||
const selectionPreview = (path: string, selection: FileSelection) => {
|
||||
const content = file.get(path)?.content?.content
|
||||
if (!content) return undefined
|
||||
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
|
||||
const end = Math.max(selection.startLine, selection.endLine)
|
||||
const lines = content.split("\n").slice(start - 1, end)
|
||||
if (lines.length === 0) return undefined
|
||||
return lines.slice(0, 2).join("\n")
|
||||
}
|
||||
|
||||
const addSelectionToContext = (path: string, selection: FileSelection) => {
|
||||
const preview = selectionPreview(path, selection)
|
||||
prompt.context.add({ type: "file", path, selection, preview })
|
||||
}
|
||||
|
||||
const sessionCommand = withCategory(language.t("command.category.session"))
|
||||
const fileCommand = withCategory(language.t("command.category.file"))
|
||||
const contextCommand = withCategory(language.t("command.category.context"))
|
||||
const viewCommand = withCategory(language.t("command.category.view"))
|
||||
const terminalCommand = withCategory(language.t("command.category.terminal"))
|
||||
const modelCommand = withCategory(language.t("command.category.model"))
|
||||
const mcpCommand = withCategory(language.t("command.category.mcp"))
|
||||
const agentCommand = withCategory(language.t("command.category.agent"))
|
||||
const permissionsCommand = withCategory(language.t("command.category.permissions"))
|
||||
|
||||
const sessionCommands = createMemo(() => [
|
||||
sessionCommand({
|
||||
id: "session.new",
|
||||
title: input.language.t("command.session.new"),
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => input.navigate(`/${input.params.dir}/session`),
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
}),
|
||||
])
|
||||
|
||||
const fileCommands = createMemo(() => [
|
||||
fileCommand({
|
||||
id: "file.open",
|
||||
title: input.language.t("command.file.open"),
|
||||
description: input.language.t("palette.search.placeholder"),
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
|
||||
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={args.showAllFiles} />),
|
||||
}),
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
title: input.language.t("command.tab.close"),
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
disabled: !input.tabs().active(),
|
||||
disabled: !tabs().active(),
|
||||
onSelect: () => {
|
||||
const active = input.tabs().active()
|
||||
const active = tabs().active()
|
||||
if (!active) return
|
||||
input.tabs().close(active)
|
||||
tabs().close(active)
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -104,30 +126,30 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const contextCommands = createMemo(() => [
|
||||
contextCommand({
|
||||
id: "context.addSelection",
|
||||
title: input.language.t("command.context.addSelection"),
|
||||
description: input.language.t("command.context.addSelection.description"),
|
||||
title: language.t("command.context.addSelection"),
|
||||
description: language.t("command.context.addSelection.description"),
|
||||
keybind: "mod+shift+l",
|
||||
disabled: !canAddSelectionContext({
|
||||
active: input.tabs().active(),
|
||||
pathFromTab: input.file.pathFromTab,
|
||||
selectedLines: input.file.selectedLines,
|
||||
active: tabs().active(),
|
||||
pathFromTab: file.pathFromTab,
|
||||
selectedLines: file.selectedLines,
|
||||
}),
|
||||
onSelect: () => {
|
||||
const active = input.tabs().active()
|
||||
const active = tabs().active()
|
||||
if (!active) return
|
||||
const path = input.file.pathFromTab(active)
|
||||
const path = file.pathFromTab(active)
|
||||
if (!path) return
|
||||
|
||||
const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
if (!range) {
|
||||
showToast({
|
||||
title: input.language.t("toast.context.noLineSelection.title"),
|
||||
description: input.language.t("toast.context.noLineSelection.description"),
|
||||
title: language.t("toast.context.noLineSelection.title"),
|
||||
description: language.t("toast.context.noLineSelection.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input.addSelectionToContext(path, selectionFromLines(range))
|
||||
addSelectionToContext(path, selectionFromLines(range))
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -135,50 +157,50 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const viewCommands = createMemo(() => [
|
||||
viewCommand({
|
||||
id: "terminal.toggle",
|
||||
title: input.language.t("command.terminal.toggle"),
|
||||
title: language.t("command.terminal.toggle"),
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => input.view().terminal.toggle(),
|
||||
onSelect: () => view().terminal.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "review.toggle",
|
||||
title: input.language.t("command.review.toggle"),
|
||||
title: language.t("command.review.toggle"),
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => input.view().reviewPanel.toggle(),
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: input.language.t("command.fileTree.toggle"),
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => input.layout.fileTree.toggle(),
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "input.focus",
|
||||
title: input.language.t("command.input.focus"),
|
||||
title: language.t("command.input.focus"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: () => input.focusInput(),
|
||||
onSelect: () => args.focusInput(),
|
||||
}),
|
||||
terminalCommand({
|
||||
id: "terminal.new",
|
||||
title: input.language.t("command.terminal.new"),
|
||||
description: input.language.t("command.terminal.new.description"),
|
||||
title: language.t("command.terminal.new"),
|
||||
description: language.t("command.terminal.new.description"),
|
||||
keybind: "ctrl+alt+t",
|
||||
onSelect: () => {
|
||||
if (input.terminal.all().length > 0) input.terminal.new()
|
||||
input.view().terminal.open()
|
||||
if (terminal.all().length > 0) terminal.new()
|
||||
view().terminal.open()
|
||||
},
|
||||
}),
|
||||
viewCommand({
|
||||
id: "steps.toggle",
|
||||
title: input.language.t("command.steps.toggle"),
|
||||
description: input.language.t("command.steps.toggle.description"),
|
||||
title: language.t("command.steps.toggle"),
|
||||
description: language.t("command.steps.toggle.description"),
|
||||
keybind: "mod+e",
|
||||
slash: "steps",
|
||||
disabled: !input.params.id,
|
||||
disabled: !params.id,
|
||||
onSelect: () => {
|
||||
const msg = input.activeMessage()
|
||||
const msg = args.activeMessage()
|
||||
if (!msg) return
|
||||
input.setExpanded(msg.id, (open: boolean | undefined) => !open)
|
||||
args.setExpanded(msg.id, (open: boolean | undefined) => !open)
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -186,61 +208,61 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const messageCommands = createMemo(() => [
|
||||
sessionCommand({
|
||||
id: "message.previous",
|
||||
title: input.language.t("command.message.previous"),
|
||||
description: input.language.t("command.message.previous.description"),
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
keybind: "mod+arrowup",
|
||||
disabled: !input.params.id,
|
||||
onSelect: () => input.navigateMessageByOffset(-1),
|
||||
disabled: !params.id,
|
||||
onSelect: () => args.navigateMessageByOffset(-1),
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "message.next",
|
||||
title: input.language.t("command.message.next"),
|
||||
description: input.language.t("command.message.next.description"),
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
keybind: "mod+arrowdown",
|
||||
disabled: !input.params.id,
|
||||
onSelect: () => input.navigateMessageByOffset(1),
|
||||
disabled: !params.id,
|
||||
onSelect: () => args.navigateMessageByOffset(1),
|
||||
}),
|
||||
])
|
||||
|
||||
const agentCommands = createMemo(() => [
|
||||
modelCommand({
|
||||
id: "model.choose",
|
||||
title: input.language.t("command.model.choose"),
|
||||
description: input.language.t("command.model.choose.description"),
|
||||
title: language.t("command.model.choose"),
|
||||
description: language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: () => input.dialog.show(() => <DialogSelectModel />),
|
||||
onSelect: () => dialog.show(() => <DialogSelectModel />),
|
||||
}),
|
||||
mcpCommand({
|
||||
id: "mcp.toggle",
|
||||
title: input.language.t("command.mcp.toggle"),
|
||||
description: input.language.t("command.mcp.toggle.description"),
|
||||
title: language.t("command.mcp.toggle"),
|
||||
description: language.t("command.mcp.toggle.description"),
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
|
||||
onSelect: () => dialog.show(() => <DialogSelectMcp />),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: input.language.t("command.agent.cycle"),
|
||||
description: input.language.t("command.agent.cycle.description"),
|
||||
title: language.t("command.agent.cycle"),
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
onSelect: () => input.local.agent.move(1),
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle.reverse",
|
||||
title: input.language.t("command.agent.cycle.reverse"),
|
||||
description: input.language.t("command.agent.cycle.reverse.description"),
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
onSelect: () => input.local.agent.move(-1),
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.variant.cycle",
|
||||
title: input.language.t("command.model.variant.cycle"),
|
||||
description: input.language.t("command.model.variant.cycle.description"),
|
||||
title: language.t("command.model.variant.cycle"),
|
||||
description: language.t("command.model.variant.cycle.description"),
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => {
|
||||
input.local.model.variant.cycle()
|
||||
local.model.variant.cycle()
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -249,22 +271,22 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
title:
|
||||
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
|
||||
? input.language.t("command.permissions.autoaccept.disable")
|
||||
: input.language.t("command.permissions.autoaccept.enable"),
|
||||
params.id && permission.isAutoAccepting(params.id, sdk.directory)
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
keybind: "mod+shift+a",
|
||||
disabled: !input.params.id || !input.permission.permissionsEnabled(),
|
||||
disabled: !params.id || !permission.permissionsEnabled(),
|
||||
onSelect: () => {
|
||||
const sessionID = input.params.id
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
|
||||
permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
showToast({
|
||||
title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
|
||||
? input.language.t("toast.permissions.autoaccept.on.title")
|
||||
: input.language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
|
||||
? input.language.t("toast.permissions.autoaccept.on.description")
|
||||
: input.language.t("toast.permissions.autoaccept.off.description"),
|
||||
title: permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
: language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
? language.t("toast.permissions.autoaccept.on.description")
|
||||
: language.t("toast.permissions.autoaccept.off.description"),
|
||||
})
|
||||
},
|
||||
}),
|
||||
@@ -273,71 +295,71 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const sessionActionCommands = createMemo(() => [
|
||||
sessionCommand({
|
||||
id: "session.undo",
|
||||
title: input.language.t("command.session.undo"),
|
||||
description: input.language.t("command.session.undo.description"),
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = input.params.id
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (input.status()?.type !== "idle") {
|
||||
await input.sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
if (status()?.type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
const revert = input.info()?.revert?.messageID
|
||||
const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
await input.sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = input.sync.data.part[message.id]
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync.data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
|
||||
input.prompt.set(restored)
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||
prompt.set(restored)
|
||||
}
|
||||
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
|
||||
input.setActiveMessage(priorMessage)
|
||||
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
|
||||
args.setActiveMessage(priorMessage)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.redo",
|
||||
title: input.language.t("command.session.redo"),
|
||||
description: input.language.t("command.session.redo.description"),
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !input.params.id || !input.info()?.revert?.messageID,
|
||||
disabled: !params.id || !info()?.revert?.messageID,
|
||||
onSelect: async () => {
|
||||
const sessionID = input.params.id
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const revertMessageID = input.info()?.revert?.messageID
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
|
||||
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!nextMessage) {
|
||||
await input.sdk.client.session.unrevert({ sessionID })
|
||||
input.prompt.reset()
|
||||
const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
|
||||
input.setActiveMessage(lastMsg)
|
||||
await sdk.client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
args.setActiveMessage(lastMsg)
|
||||
return
|
||||
}
|
||||
await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
|
||||
input.setActiveMessage(priorMsg)
|
||||
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
|
||||
args.setActiveMessage(priorMsg)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.compact",
|
||||
title: input.language.t("command.session.compact"),
|
||||
description: input.language.t("command.session.compact.description"),
|
||||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = input.params.id
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const model = input.local.model.current()
|
||||
const model = local.model.current()
|
||||
if (!model) {
|
||||
showToast({
|
||||
title: input.language.t("toast.model.none.title"),
|
||||
description: input.language.t("toast.model.none.description"),
|
||||
title: language.t("toast.model.none.title"),
|
||||
description: language.t("toast.model.none.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
await input.sdk.client.session.summarize({
|
||||
await sdk.client.session.summarize({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
@@ -346,29 +368,27 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.fork",
|
||||
title: input.language.t("command.session.fork"),
|
||||
description: input.language.t("command.session.fork.description"),
|
||||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||
onSelect: () => input.dialog.show(() => <DialogFork />),
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: () => dialog.show(() => <DialogFork />),
|
||||
}),
|
||||
])
|
||||
|
||||
const shareCommands = createMemo(() => {
|
||||
if (input.sync.data.config.share === "disabled") return []
|
||||
if (sync.data.config.share === "disabled") return []
|
||||
return [
|
||||
sessionCommand({
|
||||
id: "session.share",
|
||||
title: input.info()?.share?.url
|
||||
? input.language.t("session.share.copy.copyLink")
|
||||
: input.language.t("command.session.share"),
|
||||
description: input.info()?.share?.url
|
||||
? input.language.t("toast.session.share.success.description")
|
||||
: input.language.t("command.session.share.description"),
|
||||
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
|
||||
description: info()?.share?.url
|
||||
? language.t("toast.session.share.success.description")
|
||||
: language.t("command.session.share.description"),
|
||||
slash: "share",
|
||||
disabled: !input.params.id,
|
||||
disabled: !params.id,
|
||||
onSelect: async () => {
|
||||
if (!input.params.id) return
|
||||
if (!params.id) return
|
||||
|
||||
const write = (value: string) => {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
@@ -398,7 +418,7 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const ok = await write(url)
|
||||
if (!ok) {
|
||||
showToast({
|
||||
title: input.language.t("toast.session.share.copyFailed.title"),
|
||||
title: language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
@@ -406,27 +426,27 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
|
||||
showToast({
|
||||
title: existing
|
||||
? input.language.t("session.share.copy.copied")
|
||||
: input.language.t("toast.session.share.success.title"),
|
||||
description: input.language.t("toast.session.share.success.description"),
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("toast.session.share.success.title"),
|
||||
description: language.t("toast.session.share.success.description"),
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
const existing = input.info()?.share?.url
|
||||
const existing = info()?.share?.url
|
||||
if (existing) {
|
||||
await copy(existing, true)
|
||||
return
|
||||
}
|
||||
|
||||
const url = await input.sdk.client.session
|
||||
.share({ sessionID: input.params.id })
|
||||
const url = await sdk.client.session
|
||||
.share({ sessionID: params.id })
|
||||
.then((res) => res.data?.share?.url)
|
||||
.catch(() => undefined)
|
||||
if (!url) {
|
||||
showToast({
|
||||
title: input.language.t("toast.session.share.failed.title"),
|
||||
description: input.language.t("toast.session.share.failed.description"),
|
||||
title: language.t("toast.session.share.failed.title"),
|
||||
description: language.t("toast.session.share.failed.description"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
@@ -437,25 +457,25 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.unshare",
|
||||
title: input.language.t("command.session.unshare"),
|
||||
description: input.language.t("command.session.unshare.description"),
|
||||
title: language.t("command.session.unshare"),
|
||||
description: language.t("command.session.unshare.description"),
|
||||
slash: "unshare",
|
||||
disabled: !input.params.id || !input.info()?.share?.url,
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!input.params.id) return
|
||||
await input.sdk.client.session
|
||||
.unshare({ sessionID: input.params.id })
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID: params.id })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: input.language.t("toast.session.unshare.success.title"),
|
||||
description: input.language.t("toast.session.unshare.success.description"),
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
description: language.t("toast.session.unshare.success.description"),
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: input.language.t("toast.session.unshare.failed.title"),
|
||||
description: input.language.t("toast.session.unshare.failed.description"),
|
||||
title: language.t("toast.session.unshare.failed.title"),
|
||||
description: language.t("toast.session.unshare.failed.description"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
@@ -464,8 +484,8 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
]
|
||||
})
|
||||
|
||||
input.command.register("session", () =>
|
||||
combineCommandSections([
|
||||
command.register("session", () =>
|
||||
[
|
||||
sessionCommands(),
|
||||
fileCommands(),
|
||||
contextCommands(),
|
||||
@@ -475,6 +495,6 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
permissionCommands(),
|
||||
sessionActionCommands(),
|
||||
shareCommands(),
|
||||
]),
|
||||
].flatMap((section) => section),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -174,21 +174,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
transition: background-color 5000000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
input:-webkit-autofill {
|
||||
-webkit-text-fill-color: var(--color-text-strong) !important;
|
||||
}
|
||||
|
||||
input:-moz-autofill {
|
||||
-moz-text-fill-color: var(--color-text-strong) !important;
|
||||
}
|
||||
|
||||
[data-component="container"] {
|
||||
max-width: 67.5rem;
|
||||
margin: 0 auto;
|
||||
@@ -1249,4 +1234,19 @@ body {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
transition: background-color 5000000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
input:-webkit-autofill {
|
||||
-webkit-text-fill-color: var(--color-text-strong) !important;
|
||||
}
|
||||
|
||||
input:-moz-autofill {
|
||||
-moz-text-fill-color: var(--color-text-strong) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
91
packages/desktop/src-tauri/Cargo.lock
generated
91
packages/desktop/src-tauri/Cargo.lock
generated
@@ -2343,9 +2343,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.177"
|
||||
version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -2663,6 +2663,18 @@ dependencies = [
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodrop"
|
||||
version = "0.1.14"
|
||||
@@ -3093,6 +3105,7 @@ dependencies = [
|
||||
"listeners",
|
||||
"objc2 0.6.3",
|
||||
"objc2-web-kit",
|
||||
"process-wrap",
|
||||
"reqwest 0.12.24",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -3123,7 +3136,6 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3638,6 +3650,20 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "process-wrap"
|
||||
version = "9.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccd9713fe2c91c3c85ac388b31b89de339365d2c995146e630b5e0da9d06526a"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"indexmap 2.12.1",
|
||||
"nix 0.31.1",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
@@ -6460,11 +6486,23 @@ version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-collections 0.2.0",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future",
|
||||
"windows-future 0.2.1",
|
||||
"windows-link 0.1.3",
|
||||
"windows-numerics",
|
||||
"windows-numerics 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections 0.3.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-future 0.3.2",
|
||||
"windows-numerics 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6476,6 +6514,15 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.51.1"
|
||||
@@ -6519,7 +6566,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
"windows-threading",
|
||||
"windows-threading 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-link 0.2.1",
|
||||
"windows-threading 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6566,6 +6624,16 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
@@ -6741,6 +6809,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-version"
|
||||
version = "0.1.7"
|
||||
|
||||
@@ -34,7 +34,7 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = "1.48.0"
|
||||
tokio = { version = "1.48.0", features = ["process"] }
|
||||
listeners = "0.3"
|
||||
tauri-plugin-os = "2"
|
||||
futures = "0.3.31"
|
||||
@@ -52,6 +52,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
chrono = "0.4"
|
||||
tokio-stream = { version = "0.1.18", features = ["sync"] }
|
||||
process-wrap = { version = "9.0.3", features = ["tokio1"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18.2"
|
||||
@@ -62,14 +63,6 @@ objc2 = "0.6"
|
||||
objc2-web-kit = "0.3"
|
||||
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_JobObjects",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Security"
|
||||
] }
|
||||
|
||||
[patch.crates-io]
|
||||
specta = { git = "https://github.com/specta-rs/specta", rev = "591a5f3ddc78348abf4cbb541d599d65306d92b9" }
|
||||
specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "591a5f3ddc78348abf4cbb541d599d65306d92b9" }
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
use futures::{FutureExt, Stream, StreamExt, future};
|
||||
use process_wrap::tokio::CommandWrap;
|
||||
#[cfg(unix)]
|
||||
use process_wrap::tokio::ProcessGroup;
|
||||
#[cfg(windows)]
|
||||
use process_wrap::tokio::{JobObject, KillOnDrop};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::{process::Stdio, time::Duration};
|
||||
use tauri::{AppHandle, Manager, path::BaseDirectory};
|
||||
use tauri_plugin_shell::{
|
||||
ShellExt,
|
||||
process::{CommandChild, CommandEvent, TerminatedPayload},
|
||||
};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tauri_specta::Event;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY};
|
||||
@@ -25,6 +32,33 @@ pub struct Config {
|
||||
pub server: Option<ServerConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CommandEvent {
|
||||
Stdout(Vec<u8>),
|
||||
Stderr(Vec<u8>),
|
||||
Error(String),
|
||||
Terminated(TerminatedPayload),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TerminatedPayload {
|
||||
pub code: Option<i32>,
|
||||
pub signal: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CommandChild {
|
||||
kill: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl CommandChild {
|
||||
pub fn kill(&self) -> std::io::Result<()> {
|
||||
self.kill
|
||||
.try_send(())
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_config(app: &AppHandle) -> Option<Config> {
|
||||
let (events, _) = spawn_command(app, "debug config", &[]).ok()?;
|
||||
|
||||
@@ -190,7 +224,7 @@ pub fn spawn_command(
|
||||
app: &tauri::AppHandle,
|
||||
args: &str,
|
||||
extra_env: &[(&str, String)],
|
||||
) -> Result<(impl Stream<Item = CommandEvent> + 'static, CommandChild), tauri_plugin_shell::Error> {
|
||||
) -> Result<(impl Stream<Item = CommandEvent> + 'static, CommandChild), std::io::Error> {
|
||||
let state_dir = app
|
||||
.path()
|
||||
.resolve("", BaseDirectory::AppLocalData)
|
||||
@@ -217,7 +251,7 @@ pub fn spawn_command(
|
||||
.map(|(key, value)| (key.to_string(), value.clone())),
|
||||
);
|
||||
|
||||
let cmd = if cfg!(windows) {
|
||||
let mut cmd = if cfg!(windows) {
|
||||
if is_wsl_enabled(app) {
|
||||
tracing::info!("WSL is enabled, spawning CLI server in WSL");
|
||||
let version = app.package_info().version.to_string();
|
||||
@@ -249,18 +283,16 @@ pub fn spawn_command(
|
||||
|
||||
script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args));
|
||||
|
||||
app.shell()
|
||||
.command("wsl")
|
||||
.args(["-e", "bash", "-lc", &script.join("\n")])
|
||||
let mut cmd = Command::new("wsl");
|
||||
cmd.args(["-e", "bash", "-lc", &script.join("\n")]);
|
||||
cmd
|
||||
} else {
|
||||
let mut cmd = app
|
||||
.shell()
|
||||
.sidecar("opencode-cli")
|
||||
.unwrap()
|
||||
.args(args.split_whitespace());
|
||||
let sidecar = get_sidecar_path(app);
|
||||
let mut cmd = Command::new(sidecar);
|
||||
cmd.args(args.split_whitespace());
|
||||
|
||||
for (key, value) in envs {
|
||||
cmd = cmd.env(key, value);
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
cmd
|
||||
@@ -269,26 +301,111 @@ pub fn spawn_command(
|
||||
let sidecar = get_sidecar_path(app);
|
||||
let shell = get_user_shell();
|
||||
|
||||
let cmd = if shell.ends_with("/nu") {
|
||||
let line = if shell.ends_with("/nu") {
|
||||
format!("^\"{}\" {}", sidecar.display(), args)
|
||||
} else {
|
||||
format!("\"{}\" {}", sidecar.display(), args)
|
||||
};
|
||||
|
||||
let mut cmd = app.shell().command(&shell).args(["-il", "-c", &cmd]);
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.args(["-il", "-c", &line]);
|
||||
|
||||
for (key, value) in envs {
|
||||
cmd = cmd.env(key, value);
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
cmd
|
||||
};
|
||||
|
||||
let (rx, child) = cmd.spawn()?;
|
||||
let event_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut wrap = CommandWrap::from(cmd);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
wrap.wrap(ProcessGroup::leader());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
wrap.wrap(JobObject).wrap(KillOnDrop);
|
||||
}
|
||||
|
||||
let mut child = wrap.spawn()?;
|
||||
let stdout = child.stdout().take();
|
||||
let stderr = child.stderr().take();
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
let (kill_tx, mut kill_rx) = mpsc::channel(1);
|
||||
|
||||
if let Some(stdout) = stdout {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let _ = tx.send(CommandEvent::Stdout(line.into_bytes())).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(stderr) = stderr {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let _ = tx.send(CommandEvent::Stderr(line.into_bytes())).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break Ok(status),
|
||||
Ok(None) => {}
|
||||
Err(err) => break Err(err),
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = kill_rx.recv() => {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
|
||||
}
|
||||
};
|
||||
|
||||
match status {
|
||||
Ok(status) => {
|
||||
let payload = TerminatedPayload {
|
||||
code: status.code(),
|
||||
signal: signal_from_status(status),
|
||||
};
|
||||
let _ = tx.send(CommandEvent::Terminated(payload)).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx.send(CommandEvent::Error(err.to_string())).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let event_stream = ReceiverStream::new(rx);
|
||||
let event_stream = sqlite_migration::logs_middleware(app.clone(), event_stream);
|
||||
|
||||
Ok((event_stream, child))
|
||||
Ok((event_stream, CommandChild { kill: kill_tx }))
|
||||
}
|
||||
|
||||
fn signal_from_status(status: std::process::ExitStatus) -> Option<i32> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
return status.signal();
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = status;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serve(
|
||||
@@ -340,7 +457,6 @@ pub fn serve(
|
||||
let _ = tx.send(payload);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
future::ready(())
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
//! Windows Job Object for reliable child process cleanup.
|
||||
//!
|
||||
//! This module provides a wrapper around Windows Job Objects with the
|
||||
//! `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` flag set. When the job object handle
|
||||
//! is closed (including when the parent process exits or crashes), Windows
|
||||
//! automatically terminates all processes assigned to the job.
|
||||
//!
|
||||
//! This is more reliable than manual cleanup because it works even if:
|
||||
//! - The parent process crashes
|
||||
//! - The parent is killed via Task Manager
|
||||
//! - The RunEvent::Exit handler fails to run
|
||||
|
||||
use std::io::{Error, Result};
|
||||
#[cfg(windows)]
|
||||
use std::sync::Mutex;
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
};
|
||||
use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
|
||||
|
||||
/// A Windows Job Object configured to kill all assigned processes when closed.
|
||||
///
|
||||
/// When this struct is dropped or when the owning process exits (even abnormally),
|
||||
/// Windows will automatically terminate all processes that have been assigned to it.
|
||||
pub struct JobObject(HANDLE);
|
||||
|
||||
// SAFETY: HANDLE is just a pointer-sized value, and Windows job objects
|
||||
// can be safely accessed from multiple threads.
|
||||
unsafe impl Send for JobObject {}
|
||||
unsafe impl Sync for JobObject {}
|
||||
|
||||
impl JobObject {
|
||||
/// Creates a new anonymous job object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set.
|
||||
///
|
||||
/// When the last handle to this job is closed (including on process exit),
|
||||
/// Windows will terminate all processes assigned to the job.
|
||||
pub fn new() -> Result<Self> {
|
||||
unsafe {
|
||||
// Create an anonymous job object
|
||||
let job = CreateJobObjectW(None, None).map_err(|e| Error::other(e.message()))?;
|
||||
|
||||
// Configure the job to kill all processes when the handle is closed
|
||||
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
JobObjectExtendedLimitInformation,
|
||||
&info as *const _ as *const std::ffi::c_void,
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
.map_err(|e| Error::other(e.message()))?;
|
||||
|
||||
Ok(Self(job))
|
||||
}
|
||||
}
|
||||
|
||||
/// Assigns a process to this job object by its process ID.
|
||||
///
|
||||
/// Once assigned, the process will be terminated when this job object is dropped
|
||||
/// or when the owning process exits.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pid` - The process ID of the process to assign
|
||||
pub fn assign_pid(&self, pid: u32) -> Result<()> {
|
||||
unsafe {
|
||||
// Open a handle to the process with the minimum required permissions
|
||||
// PROCESS_SET_QUOTA and PROCESS_TERMINATE are required by AssignProcessToJobObject
|
||||
let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid)
|
||||
.map_err(|e| Error::other(e.message()))?;
|
||||
|
||||
// Assign the process to the job
|
||||
let result = AssignProcessToJobObject(self.0, process);
|
||||
|
||||
// Close our handle to the process - the job object maintains its own reference
|
||||
let _ = CloseHandle(process);
|
||||
|
||||
result.map_err(|e| Error::other(e.message()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for JobObject {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
// When this handle is closed and it's the last handle to the job,
|
||||
// Windows will terminate all processes in the job due to KILL_ON_JOB_CLOSE
|
||||
let _ = CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the Windows Job Object that ensures child processes are killed when the app exits.
|
||||
/// On Windows, when the job object handle is closed (including on crash), all assigned
|
||||
/// processes are automatically terminated by the OS.
|
||||
#[cfg(windows)]
|
||||
pub struct JobObjectState {
|
||||
job: Mutex<Option<JobObject>>,
|
||||
error: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl JobObjectState {
|
||||
pub fn new() -> Self {
|
||||
match JobObject::new() {
|
||||
Ok(job) => Self {
|
||||
job: Mutex::new(Some(job)),
|
||||
error: Mutex::new(None),
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create job object: {e}");
|
||||
Self {
|
||||
job: Mutex::new(None),
|
||||
error: Mutex::new(Some(format!("Failed to create job object: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assign_pid(&self, pid: u32) {
|
||||
if let Some(job) = self.job.lock().unwrap().as_ref() {
|
||||
if let Err(e) = job.assign_pid(pid) {
|
||||
tracing::error!(pid, "Failed to assign process to job object: {e}");
|
||||
*self.error.lock().unwrap() =
|
||||
Some(format!("Failed to assign process to job object: {e}"));
|
||||
} else {
|
||||
tracing::info!(pid, "Assigned process to job object for automatic cleanup");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_job_object_creation() {
|
||||
let job = JobObject::new();
|
||||
assert!(job.is_ok(), "Failed to create job object: {:?}", job.err());
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
mod cli;
|
||||
mod constants;
|
||||
#[cfg(windows)]
|
||||
mod job_object;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux_display;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux_windowing;
|
||||
mod logging;
|
||||
mod markdown;
|
||||
mod server;
|
||||
mod window_customizer;
|
||||
mod windows;
|
||||
|
||||
use crate::cli::CommandChild;
|
||||
use futures::{
|
||||
FutureExt, TryFutureExt,
|
||||
future::{self, Shared},
|
||||
};
|
||||
#[cfg(windows)]
|
||||
use job_object::*;
|
||||
use std::{
|
||||
env,
|
||||
net::TcpListener,
|
||||
@@ -27,7 +26,6 @@ use std::{
|
||||
use tauri::{AppHandle, Listener, Manager, RunEvent, State, ipc::Channel};
|
||||
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri_plugin_shell::process::CommandChild;
|
||||
use tauri_specta::Event;
|
||||
use tokio::{
|
||||
sync::{oneshot, watch},
|
||||
@@ -631,12 +629,6 @@ async fn initialize(app: AppHandle) {
|
||||
|
||||
tracing::info!("CLI health check OK");
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let job_state = app.state::<JobObjectState>();
|
||||
job_state.assign_pid(child.pid());
|
||||
}
|
||||
|
||||
app.state::<ServerState>().set_child(Some(child));
|
||||
|
||||
Ok(ServerReadyData { url, password })
|
||||
@@ -710,9 +702,6 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
|
||||
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||
app.deep_link().register_all().ok();
|
||||
|
||||
#[cfg(windows)]
|
||||
app.manage(JobObjectState::new());
|
||||
|
||||
app.manage(InitState { current: init_rx });
|
||||
}
|
||||
|
||||
|
||||
475
packages/desktop/src-tauri/src/linux_windowing.rs
Normal file
475
packages/desktop/src-tauri/src/linux_windowing.rs
Normal file
@@ -0,0 +1,475 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Backend {
|
||||
Auto,
|
||||
Wayland,
|
||||
X11,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendDecision {
|
||||
pub backend: Backend,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SessionEnv {
|
||||
pub wayland_display: bool,
|
||||
pub xdg_session_type: Option<String>,
|
||||
pub display: bool,
|
||||
pub xdg_current_desktop: Option<String>,
|
||||
pub xdg_session_desktop: Option<String>,
|
||||
pub desktop_session: Option<String>,
|
||||
pub oc_allow_wayland: Option<String>,
|
||||
pub oc_force_x11: Option<String>,
|
||||
pub oc_force_wayland: Option<String>,
|
||||
pub oc_linux_decorations: Option<String>,
|
||||
pub oc_force_decorations: Option<String>,
|
||||
pub oc_no_decorations: Option<String>,
|
||||
pub i3_sock: bool,
|
||||
}
|
||||
|
||||
impl SessionEnv {
|
||||
pub fn capture() -> Self {
|
||||
Self {
|
||||
wayland_display: std::env::var_os("WAYLAND_DISPLAY").is_some(),
|
||||
xdg_session_type: std::env::var("XDG_SESSION_TYPE").ok(),
|
||||
display: std::env::var_os("DISPLAY").is_some(),
|
||||
xdg_current_desktop: std::env::var("XDG_CURRENT_DESKTOP").ok(),
|
||||
xdg_session_desktop: std::env::var("XDG_SESSION_DESKTOP").ok(),
|
||||
desktop_session: std::env::var("DESKTOP_SESSION").ok(),
|
||||
oc_allow_wayland: std::env::var("OC_ALLOW_WAYLAND").ok(),
|
||||
oc_force_x11: std::env::var("OC_FORCE_X11").ok(),
|
||||
oc_force_wayland: std::env::var("OC_FORCE_WAYLAND").ok(),
|
||||
oc_linux_decorations: std::env::var("OC_LINUX_DECORATIONS").ok(),
|
||||
oc_force_decorations: std::env::var("OC_FORCE_DECORATIONS").ok(),
|
||||
oc_no_decorations: std::env::var("OC_NO_DECORATIONS").ok(),
|
||||
i3_sock: std::env::var_os("I3SOCK").is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_backend(env: &SessionEnv, prefer_wayland: bool) -> Option<BackendDecision> {
|
||||
if is_truthy(env.oc_force_x11.as_deref()) {
|
||||
return Some(BackendDecision {
|
||||
backend: Backend::X11,
|
||||
note: "Forcing X11 due to OC_FORCE_X11=1".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if is_truthy(env.oc_force_wayland.as_deref()) {
|
||||
return Some(BackendDecision {
|
||||
backend: Backend::Wayland,
|
||||
note: "Forcing native Wayland due to OC_FORCE_WAYLAND=1".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if !is_wayland_session(env) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if prefer_wayland {
|
||||
return Some(BackendDecision {
|
||||
backend: Backend::Wayland,
|
||||
note: "Wayland session detected; forcing native Wayland from settings".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if is_truthy(env.oc_allow_wayland.as_deref()) {
|
||||
return Some(BackendDecision {
|
||||
backend: Backend::Wayland,
|
||||
note: "Wayland session detected; forcing native Wayland due to OC_ALLOW_WAYLAND=1"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
|
||||
Some(BackendDecision {
|
||||
backend: Backend::Auto,
|
||||
note: "Wayland session detected; using native Wayland first with X11 fallback (auto backend). Set OC_FORCE_X11=1 to force X11."
|
||||
.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn use_decorations(env: &SessionEnv) -> bool {
|
||||
if let Some(mode) = decoration_override(env.oc_linux_decorations.as_deref()) {
|
||||
return match mode {
|
||||
DecorationOverride::Native => true,
|
||||
DecorationOverride::None => false,
|
||||
DecorationOverride::Auto => default_use_decorations(env),
|
||||
};
|
||||
}
|
||||
|
||||
if is_truthy(env.oc_force_decorations.as_deref()) {
|
||||
return true;
|
||||
}
|
||||
if is_truthy(env.oc_no_decorations.as_deref()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
default_use_decorations(env)
|
||||
}
|
||||
|
||||
fn default_use_decorations(env: &SessionEnv) -> bool {
|
||||
if is_known_tiling_session(env) {
|
||||
return false;
|
||||
}
|
||||
if !is_wayland_session(env) {
|
||||
return true;
|
||||
}
|
||||
is_full_desktop_session(env)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DecorationOverride {
|
||||
Auto,
|
||||
Native,
|
||||
None,
|
||||
}
|
||||
|
||||
fn decoration_override(value: Option<&str>) -> Option<DecorationOverride> {
|
||||
let value = value?.trim().to_ascii_lowercase();
|
||||
if matches!(value.as_str(), "auto") {
|
||||
return Some(DecorationOverride::Auto);
|
||||
}
|
||||
if matches!(
|
||||
value.as_str(),
|
||||
"native" | "server" | "de" | "wayland" | "on" | "true" | "1"
|
||||
) {
|
||||
return Some(DecorationOverride::Native);
|
||||
}
|
||||
if matches!(
|
||||
value.as_str(),
|
||||
"none" | "off" | "false" | "0" | "client" | "csd"
|
||||
) {
|
||||
return Some(DecorationOverride::None);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_truthy(value: Option<&str>) -> bool {
|
||||
matches!(
|
||||
value.map(|v| v.trim().to_ascii_lowercase()),
|
||||
Some(v) if matches!(v.as_str(), "1" | "true" | "yes" | "on")
|
||||
)
|
||||
}
|
||||
|
||||
fn is_wayland_session(env: &SessionEnv) -> bool {
|
||||
env.wayland_display
|
||||
|| matches!(
|
||||
env.xdg_session_type.as_deref(),
|
||||
Some(value) if value.eq_ignore_ascii_case("wayland")
|
||||
)
|
||||
}
|
||||
|
||||
fn is_full_desktop_session(env: &SessionEnv) -> bool {
|
||||
desktop_tokens(env).any(|value| {
|
||||
matches!(
|
||||
value.as_str(),
|
||||
"gnome"
|
||||
| "kde"
|
||||
| "plasma"
|
||||
| "xfce"
|
||||
| "xfce4"
|
||||
| "x-cinnamon"
|
||||
| "cinnamon"
|
||||
| "mate"
|
||||
| "lxqt"
|
||||
| "budgie"
|
||||
| "pantheon"
|
||||
| "deepin"
|
||||
| "unity"
|
||||
| "cosmic"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_known_tiling_session(env: &SessionEnv) -> bool {
|
||||
if env.i3_sock {
|
||||
return true;
|
||||
}
|
||||
|
||||
desktop_tokens(env).any(|value| {
|
||||
matches!(
|
||||
value.as_str(),
|
||||
"niri"
|
||||
| "sway"
|
||||
| "swayfx"
|
||||
| "hyprland"
|
||||
| "river"
|
||||
| "i3"
|
||||
| "i3wm"
|
||||
| "bspwm"
|
||||
| "dwm"
|
||||
| "qtile"
|
||||
| "xmonad"
|
||||
| "leftwm"
|
||||
| "dwl"
|
||||
| "awesome"
|
||||
| "herbstluftwm"
|
||||
| "spectrwm"
|
||||
| "worm"
|
||||
| "i3-gnome"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn desktop_tokens<'a>(env: &'a SessionEnv) -> impl Iterator<Item = String> + 'a {
|
||||
[
|
||||
env.xdg_current_desktop.as_deref(),
|
||||
env.xdg_session_desktop.as_deref(),
|
||||
env.desktop_session.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|desktop| desktop.split(':'))
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefers_wayland_first_on_wayland_session() {
|
||||
let env = SessionEnv {
|
||||
wayland_display: true,
|
||||
display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decision = select_backend(&env, false).expect("missing decision");
|
||||
assert_eq!(decision.backend, Backend::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_x11_override_wins() {
|
||||
let env = SessionEnv {
|
||||
wayland_display: true,
|
||||
display: true,
|
||||
oc_force_x11: Some("1".into()),
|
||||
oc_allow_wayland: Some("1".into()),
|
||||
oc_force_wayland: Some("1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decision = select_backend(&env, true).expect("missing decision");
|
||||
assert_eq!(decision.backend, Backend::X11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefer_wayland_forces_wayland_backend() {
|
||||
let env = SessionEnv {
|
||||
wayland_display: true,
|
||||
display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decision = select_backend(&env, true).expect("missing decision");
|
||||
assert_eq!(decision.backend, Backend::Wayland);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_wayland_override_works_outside_wayland_session() {
|
||||
let env = SessionEnv {
|
||||
display: true,
|
||||
oc_force_wayland: Some("1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decision = select_backend(&env, false).expect("missing decision");
|
||||
assert_eq!(decision.backend, Backend::Wayland);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_wayland_forces_wayland_backend() {
|
||||
let env = SessionEnv {
|
||||
wayland_display: true,
|
||||
display: true,
|
||||
oc_allow_wayland: Some("1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decision = select_backend(&env, false).expect("missing decision");
|
||||
assert_eq!(decision.backend, Backend::Wayland);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_session_type_wayland_is_detected() {
|
||||
let env = SessionEnv {
|
||||
xdg_session_type: Some("wayland".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decision = select_backend(&env, false).expect("missing decision");
|
||||
assert_eq!(decision.backend, Backend::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_not_wayland_and_no_overrides() {
|
||||
let env = SessionEnv {
|
||||
display: true,
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(select_backend(&env, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefer_wayland_setting_does_not_override_x11_session() {
|
||||
let env = SessionEnv {
|
||||
display: true,
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(select_backend(&env, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_decorations_on_niri() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("niri".into()),
|
||||
wayland_display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_decorations_on_gnome() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
wayland_display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_decorations_when_session_desktop_is_tiling() {
|
||||
let env = SessionEnv {
|
||||
xdg_session_desktop: Some("Hyprland".into()),
|
||||
wayland_display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_decorations_for_unknown_wayland_session() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("labwc".into()),
|
||||
wayland_display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_decorations_for_dwm_on_x11() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("dwm".into()),
|
||||
display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_decorations_for_i3_on_x11() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("i3".into()),
|
||||
display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_decorations_for_i3sock_without_xdg_tokens() {
|
||||
let env = SessionEnv {
|
||||
display: true,
|
||||
i3_sock: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_decorations_for_gnome_on_x11() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_decorations_override_wins() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
oc_no_decorations: Some("1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_decorations_native_override_wins() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("niri".into()),
|
||||
wayland_display: true,
|
||||
oc_linux_decorations: Some("native".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_decorations_none_override_wins() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
wayland_display: true,
|
||||
oc_linux_decorations: Some("none".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_decorations_auto_uses_default_policy() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("sway".into()),
|
||||
wayland_display: true,
|
||||
oc_linux_decorations: Some("auto".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_decorations_override_beats_legacy_overrides() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
wayland_display: true,
|
||||
oc_linux_decorations: Some("none".into()),
|
||||
oc_force_decorations: Some("1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,7 @@ pub fn init(log_dir: &Path) -> WorkerGuard {
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt::layer().with_writer(std::io::stderr))
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false),
|
||||
)
|
||||
.with(fmt::layer().with_writer(non_blocking).with_ansi(false))
|
||||
.init();
|
||||
|
||||
guard
|
||||
@@ -55,10 +51,7 @@ pub fn tail() -> String {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let lines: Vec<String> = BufReader::new(file)
|
||||
.lines()
|
||||
.map_while(Result::ok)
|
||||
.collect();
|
||||
let lines: Vec<String> = BufReader::new(file).lines().map_while(Result::ok).collect();
|
||||
|
||||
let start = lines.len().saturating_sub(TAIL_LINES);
|
||||
lines[start..].join("\n")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// borrowed from https://github.com/skyline69/balatro-mod-manager
|
||||
#[cfg(target_os = "linux")]
|
||||
fn configure_display_backend() -> Option<String> {
|
||||
use opencode_lib::linux_windowing::{Backend, SessionEnv, select_backend};
|
||||
use std::env;
|
||||
|
||||
let set_env_if_absent = |key: &str, value: &str| {
|
||||
@@ -14,45 +15,28 @@ fn configure_display_backend() -> Option<String> {
|
||||
}
|
||||
};
|
||||
|
||||
let on_wayland = env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
|| matches!(
|
||||
env::var("XDG_SESSION_TYPE"),
|
||||
Ok(v) if v.eq_ignore_ascii_case("wayland")
|
||||
);
|
||||
if !on_wayland {
|
||||
return None;
|
||||
}
|
||||
|
||||
let session = SessionEnv::capture();
|
||||
let prefer_wayland = opencode_lib::linux_display::read_wayland().unwrap_or(false);
|
||||
let allow_wayland = prefer_wayland
|
||||
|| matches!(
|
||||
env::var("OC_ALLOW_WAYLAND"),
|
||||
Ok(v) if matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes")
|
||||
);
|
||||
if allow_wayland {
|
||||
if prefer_wayland {
|
||||
return Some("Wayland session detected; using native Wayland from settings".into());
|
||||
let decision = select_backend(&session, prefer_wayland)?;
|
||||
|
||||
match decision.backend {
|
||||
Backend::X11 => {
|
||||
set_env_if_absent("WINIT_UNIX_BACKEND", "x11");
|
||||
set_env_if_absent("GDK_BACKEND", "x11");
|
||||
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
Backend::Wayland => {
|
||||
set_env_if_absent("WINIT_UNIX_BACKEND", "wayland");
|
||||
set_env_if_absent("GDK_BACKEND", "wayland");
|
||||
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
Backend::Auto => {
|
||||
set_env_if_absent("GDK_BACKEND", "wayland,x11");
|
||||
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
return Some("Wayland session detected; respecting OC_ALLOW_WAYLAND=1".into());
|
||||
}
|
||||
|
||||
// Prefer XWayland when available to avoid Wayland protocol errors seen during startup.
|
||||
if env::var_os("DISPLAY").is_some() {
|
||||
set_env_if_absent("WINIT_UNIX_BACKEND", "x11");
|
||||
set_env_if_absent("GDK_BACKEND", "x11");
|
||||
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
return Some(
|
||||
"Wayland session detected; forcing X11 backend to avoid compositor protocol errors. \
|
||||
Set OC_ALLOW_WAYLAND=1 to keep native Wayland."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
Some(
|
||||
"Wayland session detected without X11; leaving Wayland enabled (set WINIT_UNIX_BACKEND/GDK_BACKEND manually if needed)."
|
||||
.into(),
|
||||
)
|
||||
Some(decision.note)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
||||
@@ -2,12 +2,12 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||
use tauri_plugin_shell::process::CommandChild;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::{
|
||||
cli,
|
||||
cli::CommandChild,
|
||||
constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,22 @@ use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindo
|
||||
use tauri_plugin_window_state::AppHandleExt;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn use_decorations() -> bool {
|
||||
static DECORATIONS: OnceLock<bool> = OnceLock::new();
|
||||
*DECORATIONS.get_or_init(|| {
|
||||
crate::linux_windowing::use_decorations(&crate::linux_windowing::SessionEnv::capture())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn use_decorations() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub struct MainWindow(WebviewWindow);
|
||||
|
||||
impl Deref for MainWindow {
|
||||
@@ -22,6 +38,8 @@ impl MainWindow {
|
||||
|
||||
pub fn create(app: &AppHandle) -> Result<Self, tauri::Error> {
|
||||
if let Some(window) = app.get_webview_window(Self::LABEL) {
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
return Ok(Self(window));
|
||||
}
|
||||
|
||||
@@ -29,13 +47,13 @@ impl MainWindow {
|
||||
.ok()
|
||||
.map(|v| v.enabled)
|
||||
.unwrap_or(false);
|
||||
|
||||
let decorations = use_decorations();
|
||||
let window_builder = base_window_config(
|
||||
WebviewWindowBuilder::new(app, Self::LABEL, WebviewUrl::App("/".into())),
|
||||
app,
|
||||
decorations,
|
||||
)
|
||||
.title("OpenCode")
|
||||
.decorations(true)
|
||||
.disable_drag_drop_handler()
|
||||
.zoom_hotkeys_enabled(false)
|
||||
.visible(true)
|
||||
@@ -50,6 +68,9 @@ impl MainWindow {
|
||||
|
||||
let window = window_builder.build()?;
|
||||
|
||||
// Ensure window is focused after creation (e.g., after update/relaunch)
|
||||
let _ = window.set_focus();
|
||||
|
||||
setup_window_state_listener(app, &window);
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -108,9 +129,12 @@ impl LoadingWindow {
|
||||
pub const LABEL: &str = "loading";
|
||||
|
||||
pub fn create(app: &AppHandle) -> Result<Self, tauri::Error> {
|
||||
let decorations = use_decorations();
|
||||
|
||||
let window_builder = base_window_config(
|
||||
WebviewWindowBuilder::new(app, Self::LABEL, tauri::WebviewUrl::App("/loading".into())),
|
||||
app,
|
||||
decorations,
|
||||
)
|
||||
.center()
|
||||
.resizable(false)
|
||||
@@ -124,8 +148,9 @@ impl LoadingWindow {
|
||||
fn base_window_config<'a, R: Runtime, M: Manager<R>>(
|
||||
window_builder: WebviewWindowBuilder<'a, R, M>,
|
||||
_app: &AppHandle,
|
||||
decorations: bool,
|
||||
) -> WebviewWindowBuilder<'a, R, M> {
|
||||
let window_builder = window_builder.decorations(true);
|
||||
let window_builder = window_builder.decorations(decorations);
|
||||
|
||||
#[cfg(windows)]
|
||||
let window_builder = window_builder
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.1"
|
||||
version = "1.2.6"
|
||||
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.1/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/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.1/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.1/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/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.1/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/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.1/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.6",
|
||||
"$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.1",
|
||||
"version": "1.2.6",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -62,7 +62,7 @@
|
||||
"@ai-sdk/deepinfra": "1.0.36",
|
||||
"@ai-sdk/gateway": "2.0.30",
|
||||
"@ai-sdk/google": "2.0.52",
|
||||
"@ai-sdk/google-vertex": "3.0.98",
|
||||
"@ai-sdk/google-vertex": "3.0.103",
|
||||
"@ai-sdk/groq": "2.0.34",
|
||||
"@ai-sdk/mistral": "2.0.27",
|
||||
"@ai-sdk/openai": "2.0.89",
|
||||
@@ -74,8 +74,8 @@
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.5.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.2",
|
||||
"@gitlab/gitlab-ai-provider": "3.5.1",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
|
||||
@@ -44,6 +44,16 @@ opencode acp
|
||||
opencode acp --cwd /path/to/project
|
||||
```
|
||||
|
||||
### Question Tool Opt-In
|
||||
|
||||
ACP excludes `QuestionTool` by default.
|
||||
|
||||
```bash
|
||||
OPENCODE_ENABLE_QUESTION_TOOL=1 opencode acp
|
||||
```
|
||||
|
||||
Enable this only for ACP clients that support interactive question prompts.
|
||||
|
||||
### Programmatic
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -21,7 +21,6 @@ export class ACPSessionManager {
|
||||
const session = await this.sdk.session
|
||||
.create(
|
||||
{
|
||||
title: `ACP Session ${crypto.randomUUID()}`,
|
||||
directory: cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
|
||||
118
packages/opencode/src/cli/cmd/db.ts
Normal file
118
packages/opencode/src/cli/cmd/db.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { spawn } from "child_process"
|
||||
import { Database } from "../../storage/db"
|
||||
import { Database as BunDatabase } from "bun:sqlite"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { JsonMigration } from "../../storage/json-migration"
|
||||
import { EOL } from "os"
|
||||
|
||||
const QueryCommand = cmd({
|
||||
command: "$0 [query]",
|
||||
describe: "open an interactive sqlite3 shell or run a query",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("query", {
|
||||
type: "string",
|
||||
describe: "SQL query to execute",
|
||||
})
|
||||
.option("format", {
|
||||
type: "string",
|
||||
choices: ["json", "tsv"],
|
||||
default: "tsv",
|
||||
describe: "Output format",
|
||||
})
|
||||
},
|
||||
handler: async (args: { query?: string; format: string }) => {
|
||||
const query = args.query as string | undefined
|
||||
if (query) {
|
||||
const db = new BunDatabase(Database.Path, { readonly: true })
|
||||
try {
|
||||
const result = db.query(query).all() as Record<string, unknown>[]
|
||||
if (args.format === "json") {
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
} else if (result.length > 0) {
|
||||
const keys = Object.keys(result[0])
|
||||
console.log(keys.join("\t"))
|
||||
for (const row of result) {
|
||||
console.log(keys.map((k) => row[k]).join("\t"))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
UI.error(err instanceof Error ? err.message : String(err))
|
||||
process.exit(1)
|
||||
}
|
||||
db.close()
|
||||
return
|
||||
}
|
||||
const child = spawn("sqlite3", [Database.Path], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
await new Promise((resolve) => child.on("close", resolve))
|
||||
},
|
||||
})
|
||||
|
||||
const PathCommand = cmd({
|
||||
command: "path",
|
||||
describe: "print the database path",
|
||||
handler: () => {
|
||||
console.log(Database.Path)
|
||||
},
|
||||
})
|
||||
|
||||
const MigrateCommand = cmd({
|
||||
command: "migrate",
|
||||
describe: "migrate JSON data to SQLite (merges with existing data)",
|
||||
handler: async () => {
|
||||
const sqlite = new BunDatabase(Database.Path)
|
||||
const tty = process.stderr.isTTY
|
||||
const width = 36
|
||||
const orange = "\x1b[38;5;214m"
|
||||
const muted = "\x1b[0;2m"
|
||||
const reset = "\x1b[0m"
|
||||
let last = -1
|
||||
if (tty) process.stderr.write("\x1b[?25l")
|
||||
try {
|
||||
const stats = await JsonMigration.run(sqlite, {
|
||||
progress: (event) => {
|
||||
const percent = Math.floor((event.current / event.total) * 100)
|
||||
if (percent === last) return
|
||||
last = percent
|
||||
if (tty) {
|
||||
const fill = Math.round((percent / 100) * width)
|
||||
const bar = `${"■".repeat(fill)}${"・".repeat(width - fill)}`
|
||||
process.stderr.write(
|
||||
`\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.current}/${event.total}${reset} `,
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(`sqlite-migration:${percent}${EOL}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
if (tty) process.stderr.write("\n")
|
||||
if (tty) process.stderr.write("\x1b[?25h")
|
||||
else process.stderr.write(`sqlite-migration:done${EOL}`)
|
||||
UI.println(
|
||||
`Migration complete: ${stats.projects} projects, ${stats.sessions} sessions, ${stats.messages} messages`,
|
||||
)
|
||||
if (stats.errors.length > 0) {
|
||||
UI.println(`${stats.errors.length} errors occurred during migration`)
|
||||
}
|
||||
} catch (err) {
|
||||
if (tty) process.stderr.write("\x1b[?25h")
|
||||
UI.error(`Migration failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
sqlite.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const DbCommand = cmd({
|
||||
command: "db",
|
||||
describe: "database tools",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs.command(QueryCommand).command(PathCommand).command(MigrateCommand).demandCommand()
|
||||
},
|
||||
handler: () => {},
|
||||
})
|
||||
@@ -38,10 +38,34 @@ function pagerCmd(): string[] {
|
||||
export const SessionCommand = cmd({
|
||||
command: "session",
|
||||
describe: "manage sessions",
|
||||
builder: (yargs: Argv) => yargs.command(SessionListCommand).demandCommand(),
|
||||
builder: (yargs: Argv) => yargs.command(SessionListCommand).command(SessionDeleteCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const SessionDeleteCommand = cmd({
|
||||
command: "delete <sessionID>",
|
||||
describe: "delete a session",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs.positional("sessionID", {
|
||||
describe: "session ID to delete",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
try {
|
||||
await Session.get(args.sessionID)
|
||||
} catch {
|
||||
UI.error(`Session not found: ${args.sessionID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
await Session.remove(args.sessionID)
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const SessionListCommand = cmd({
|
||||
command: "list",
|
||||
describe: "list sessions",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cmd } from "../cmd"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { tui } from "./app"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
|
||||
@@ -16,11 +17,20 @@ export const AttachCommand = cmd({
|
||||
type: "string",
|
||||
description: "directory to run in",
|
||||
})
|
||||
.option("continue", {
|
||||
alias: ["c"],
|
||||
describe: "continue the last session",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
type: "string",
|
||||
describe: "session id to continue",
|
||||
})
|
||||
.option("fork", {
|
||||
type: "boolean",
|
||||
describe: "fork the session when continuing (use with --continue or --session)",
|
||||
})
|
||||
.option("password", {
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
@@ -31,6 +41,12 @@ export const AttachCommand = cmd({
|
||||
try {
|
||||
win32DisableProcessedInput()
|
||||
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
try {
|
||||
@@ -49,7 +65,11 @@ export const AttachCommand = cmd({
|
||||
})()
|
||||
await tui({
|
||||
url: args.url,
|
||||
args: { sessionID: args.session },
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
fork: args.fork,
|
||||
},
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
|
||||
@@ -247,7 +247,8 @@ export function Autocomplete(props: {
|
||||
const width = props.anchor().width - 4
|
||||
options.push(
|
||||
...sortedFiles.map((item): AutocompleteOption => {
|
||||
const fullPath = `${process.cwd()}/${item}`
|
||||
const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
|
||||
const fullPath = `${baseDir}/${item}`
|
||||
const urlObj = pathToFileURL(fullPath)
|
||||
let filename = item
|
||||
if (lineRange && !item.endsWith("/")) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createMemo, createSignal, For } from "solid-js"
|
||||
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
|
||||
|
||||
const themeCount = Object.keys(DEFAULT_THEMES).length
|
||||
const themeTip = `Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${themeCount} built-in themes`
|
||||
const themeTip = `Use {highlight}/themes{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${themeCount} built-in themes`
|
||||
|
||||
type TipPart = { text: string; highlight: boolean }
|
||||
|
||||
@@ -126,7 +126,7 @@ const TIPS = [
|
||||
"Use {highlight}{file:path}{/highlight} to include file contents in config values",
|
||||
"Use {highlight}instructions{/highlight} in config to load additional rules files",
|
||||
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
|
||||
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request",
|
||||
"Configure {highlight}steps{/highlight} to limit agentic iterations per request",
|
||||
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools',
|
||||
'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server',
|
||||
"Override global tool settings per agent configuration",
|
||||
@@ -147,7 +147,6 @@ const TIPS = [
|
||||
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
|
||||
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
|
||||
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog",
|
||||
"Use {highlight}/details{/highlight} to toggle tool execution details visibility",
|
||||
"Use {highlight}/rename{/highlight} to rename the current session",
|
||||
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell",
|
||||
]
|
||||
|
||||
@@ -30,6 +30,7 @@ export namespace Flag {
|
||||
export declare const OPENCODE_CLIENT: string
|
||||
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
|
||||
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
|
||||
export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")
|
||||
|
||||
// Experimental
|
||||
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
|
||||
@@ -364,3 +364,21 @@ export const ormolu: Info = {
|
||||
return Bun.which("ormolu") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const cljfmt: Info = {
|
||||
name: "cljfmt",
|
||||
command: ["cljfmt", "fix", "--quiet", "$FILE"],
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
async enabled() {
|
||||
return Bun.which("cljfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const dfmt: Info = {
|
||||
name: "dfmt",
|
||||
command: ["dfmt", "-i", "$FILE"],
|
||||
extensions: [".d"],
|
||||
async enabled() {
|
||||
return Bun.which("dfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { EOL } from "os"
|
||||
import { WebCommand } from "./cli/cmd/web"
|
||||
import { PrCommand } from "./cli/cmd/pr"
|
||||
import { SessionCommand } from "./cli/cmd/session"
|
||||
import { DbCommand } from "./cli/cmd/db"
|
||||
import path from "path"
|
||||
import { Global } from "./global"
|
||||
import { JsonMigration } from "./storage/json-migration"
|
||||
@@ -81,14 +82,14 @@ const cli = yargs(hideBin(process.argv))
|
||||
|
||||
const marker = path.join(Global.Path.data, "opencode.db")
|
||||
if (!(await Bun.file(marker).exists())) {
|
||||
console.log("Performing one time database migration, may take a few minutes...")
|
||||
const tty = process.stdout.isTTY
|
||||
const tty = process.stderr.isTTY
|
||||
process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)
|
||||
const width = 36
|
||||
const orange = "\x1b[38;5;214m"
|
||||
const muted = "\x1b[0;2m"
|
||||
const reset = "\x1b[0m"
|
||||
let last = -1
|
||||
if (tty) process.stdout.write("\x1b[?25l")
|
||||
if (tty) process.stderr.write("\x1b[?25l")
|
||||
try {
|
||||
await JsonMigration.run(Database.Client().$client, {
|
||||
progress: (event) => {
|
||||
@@ -98,22 +99,22 @@ const cli = yargs(hideBin(process.argv))
|
||||
if (tty) {
|
||||
const fill = Math.round((percent / 100) * width)
|
||||
const bar = `${"■".repeat(fill)}${"・".repeat(width - fill)}`
|
||||
process.stdout.write(
|
||||
process.stderr.write(
|
||||
`\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.label.padEnd(12)} ${event.current}/${event.total}${reset}`,
|
||||
)
|
||||
if (event.current === event.total) process.stdout.write("\n")
|
||||
if (event.current === event.total) process.stderr.write("\n")
|
||||
} else {
|
||||
console.log(`sqlite-migration:${percent}`)
|
||||
process.stderr.write(`sqlite-migration:${percent}${EOL}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (tty) process.stdout.write("\x1b[?25h")
|
||||
if (tty) process.stderr.write("\x1b[?25h")
|
||||
else {
|
||||
console.log(`sqlite-migration:done`)
|
||||
process.stderr.write(`sqlite-migration:done${EOL}`)
|
||||
}
|
||||
}
|
||||
console.log("Database migration complete.")
|
||||
process.stderr.write("Database migration complete." + EOL)
|
||||
}
|
||||
})
|
||||
.usage("\n" + UI.logo())
|
||||
@@ -138,6 +139,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
.command(GithubCommand)
|
||||
.command(PrCommand)
|
||||
.command(SessionCommand)
|
||||
.command(DbCommand)
|
||||
.fail((msg, err) => {
|
||||
if (
|
||||
msg?.startsWith("Unknown argument") ||
|
||||
@@ -188,7 +190,7 @@ try {
|
||||
if (formatted) UI.error(formatted)
|
||||
if (formatted === undefined) {
|
||||
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL)
|
||||
}
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
|
||||
@@ -57,6 +57,30 @@ export namespace Provider {
|
||||
return isGpt5OrLater(modelID) && !modelID.startsWith("gpt-5-mini")
|
||||
}
|
||||
|
||||
function googleVertexVars(options: Record<string, any>) {
|
||||
const project =
|
||||
options["project"] ?? Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
|
||||
const location =
|
||||
options["location"] ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
|
||||
const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
|
||||
|
||||
return {
|
||||
GOOGLE_VERTEX_PROJECT: project,
|
||||
GOOGLE_VERTEX_LOCATION: location,
|
||||
GOOGLE_VERTEX_ENDPOINT: endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
function loadBaseURL(model: Model, options: Record<string, any>) {
|
||||
const raw = options["baseURL"] ?? model.api.url
|
||||
if (typeof raw !== "string") return raw
|
||||
const vars = model.providerID === "google-vertex" ? googleVertexVars(options) : undefined
|
||||
return raw.replace(/\$\{([^}]+)\}/g, (match, key) => {
|
||||
const val = Env.get(String(key)) ?? vars?.[String(key) as keyof typeof vars]
|
||||
return val ?? match
|
||||
})
|
||||
}
|
||||
|
||||
const BUNDLED_PROVIDERS: Record<string, (options: any) => SDK> = {
|
||||
"@ai-sdk/amazon-bedrock": createAmazonBedrock,
|
||||
"@ai-sdk/anthropic": createAnthropic,
|
||||
@@ -353,9 +377,16 @@ export namespace Provider {
|
||||
},
|
||||
}
|
||||
},
|
||||
"google-vertex": async () => {
|
||||
const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
|
||||
const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-east5"
|
||||
"google-vertex": async (provider) => {
|
||||
const project =
|
||||
provider.options?.project ??
|
||||
Env.get("GOOGLE_CLOUD_PROJECT") ??
|
||||
Env.get("GCP_PROJECT") ??
|
||||
Env.get("GCLOUD_PROJECT")
|
||||
|
||||
const location =
|
||||
provider.options?.location ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
|
||||
|
||||
const autoload = Boolean(project)
|
||||
if (!autoload) return { autoload: false }
|
||||
return {
|
||||
@@ -363,6 +394,18 @@ export namespace Provider {
|
||||
options: {
|
||||
project,
|
||||
location,
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const { GoogleAuth } = await import(await BunProc.install("google-auth-library"))
|
||||
const auth = new GoogleAuth()
|
||||
const client = await auth.getApplicationDefault()
|
||||
const credentials = await client.credential
|
||||
const token = await credentials.getAccessToken()
|
||||
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set("Authorization", `Bearer ${token.token}`)
|
||||
|
||||
return fetch(input, { ...init, headers })
|
||||
},
|
||||
},
|
||||
async getModel(sdk: any, modelID: string) {
|
||||
const id = String(modelID).trim()
|
||||
@@ -994,11 +1037,16 @@ export namespace Provider {
|
||||
const provider = s.providers[model.providerID]
|
||||
const options = { ...provider.options }
|
||||
|
||||
if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
|
||||
delete options.fetch
|
||||
}
|
||||
|
||||
if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
|
||||
options["includeUsage"] = true
|
||||
}
|
||||
|
||||
if (!options["baseURL"]) options["baseURL"] = model.api.url
|
||||
const baseURL = loadBaseURL(model, options)
|
||||
if (baseURL !== undefined) options["baseURL"] = baseURL
|
||||
if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
|
||||
if (model.headers)
|
||||
options["headers"] = {
|
||||
|
||||
@@ -171,7 +171,7 @@ export namespace ProviderTransform {
|
||||
return msgs
|
||||
}
|
||||
|
||||
function applyCaching(msgs: ModelMessage[], providerID: string): ModelMessage[] {
|
||||
function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
|
||||
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
|
||||
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
|
||||
|
||||
@@ -194,7 +194,7 @@ export namespace ProviderTransform {
|
||||
}
|
||||
|
||||
for (const msg of unique([...system, ...final])) {
|
||||
const useMessageLevelOptions = providerID === "anthropic" || providerID.includes("bedrock")
|
||||
const useMessageLevelOptions = model.providerID === "anthropic" || model.providerID.includes("bedrock")
|
||||
const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
|
||||
|
||||
if (shouldUseContentOptions) {
|
||||
@@ -253,14 +253,15 @@ export namespace ProviderTransform {
|
||||
msgs = unsupportedParts(msgs, model)
|
||||
msgs = normalizeMessages(msgs, model, options)
|
||||
if (
|
||||
model.providerID === "anthropic" ||
|
||||
model.api.id.includes("anthropic") ||
|
||||
model.api.id.includes("claude") ||
|
||||
model.id.includes("anthropic") ||
|
||||
model.id.includes("claude") ||
|
||||
model.api.npm === "@ai-sdk/anthropic"
|
||||
(model.providerID === "anthropic" ||
|
||||
model.api.id.includes("anthropic") ||
|
||||
model.api.id.includes("claude") ||
|
||||
model.id.includes("anthropic") ||
|
||||
model.id.includes("claude") ||
|
||||
model.api.npm === "@ai-sdk/anthropic") &&
|
||||
model.api.npm !== "@ai-sdk/gateway"
|
||||
) {
|
||||
msgs = applyCaching(msgs, model.providerID)
|
||||
msgs = applyCaching(msgs, model)
|
||||
}
|
||||
|
||||
// Remap providerOptions keys from stored providerID to expected SDK key
|
||||
@@ -297,8 +298,8 @@ export namespace ProviderTransform {
|
||||
if (id.includes("glm-4.7")) return 1.0
|
||||
if (id.includes("minimax-m2")) return 1.0
|
||||
if (id.includes("kimi-k2")) {
|
||||
// kimi-k2-thinking & kimi-k2.5 && kimi-k2p5
|
||||
if (id.includes("thinking") || id.includes("k2.") || id.includes("k2p")) {
|
||||
// kimi-k2-thinking & kimi-k2.5 && kimi-k2p5 && kimi-k2-5
|
||||
if (["thinking", "k2.", "k2p", "k2-5"].some((s) => id.includes(s))) {
|
||||
return 1.0
|
||||
}
|
||||
return 0.6
|
||||
@@ -309,7 +310,7 @@ export namespace ProviderTransform {
|
||||
export function topP(model: Provider.Model) {
|
||||
const id = model.id.toLowerCase()
|
||||
if (id.includes("qwen")) return 1
|
||||
if (id.includes("minimax-m2") || id.includes("kimi-k2.5") || id.includes("kimi-k2p5") || id.includes("gemini")) {
|
||||
if (["minimax-m2", "gemini", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) {
|
||||
return 0.95
|
||||
}
|
||||
return undefined
|
||||
@@ -318,7 +319,7 @@ export namespace ProviderTransform {
|
||||
export function topK(model: Provider.Model) {
|
||||
const id = model.id.toLowerCase()
|
||||
if (id.includes("minimax-m2")) {
|
||||
if (id.includes("m2.1")) return 40
|
||||
if (["m2.", "m25", "m21"].some((s) => id.includes(s))) return 40
|
||||
return 20
|
||||
}
|
||||
if (id.includes("gemini")) return 64
|
||||
@@ -360,11 +361,53 @@ export namespace ProviderTransform {
|
||||
|
||||
switch (model.api.npm) {
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
if (!model.id.includes("gpt") && !model.id.includes("gemini-3")) return {}
|
||||
if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("claude")) return {}
|
||||
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))
|
||||
|
||||
// TODO: YOU CANNOT SET max_tokens if this is set!!!
|
||||
case "@ai-sdk/gateway":
|
||||
if (model.id.includes("anthropic")) {
|
||||
return {
|
||||
high: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budgetTokens: 16000,
|
||||
},
|
||||
},
|
||||
max: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budgetTokens: 31999,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
if (model.id.includes("google")) {
|
||||
if (id.includes("2.5")) {
|
||||
return {
|
||||
high: {
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
thinkingBudget: 16000,
|
||||
},
|
||||
},
|
||||
max: {
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
thinkingBudget: 24576,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
["low", "high"].map((effort) => [
|
||||
effort,
|
||||
{
|
||||
includeThoughts: true,
|
||||
thinkingLevel: effort,
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
|
||||
|
||||
case "@ai-sdk/github-copilot":
|
||||
@@ -720,6 +763,15 @@ export namespace ProviderTransform {
|
||||
result["promptCacheKey"] = input.sessionID
|
||||
}
|
||||
|
||||
if (input.model.providerID === "openrouter") {
|
||||
result["prompt_cache_key"] = input.sessionID
|
||||
}
|
||||
if (input.model.api.npm === "@ai-sdk/gateway") {
|
||||
result["gateway"] = {
|
||||
caching: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -750,10 +802,51 @@ export namespace ProviderTransform {
|
||||
}
|
||||
return { reasoningEffort: "minimal" }
|
||||
}
|
||||
|
||||
if (model.providerID === "venice") {
|
||||
return { veniceParameters: { disableThinking: true } }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
// Maps model ID prefix to provider slug used in providerOptions.
|
||||
// Example: "amazon/nova-2-lite" → "bedrock"
|
||||
const SLUG_OVERRIDES: Record<string, string> = {
|
||||
amazon: "bedrock",
|
||||
}
|
||||
|
||||
export function providerOptions(model: Provider.Model, options: { [x: string]: any }) {
|
||||
if (model.api.npm === "@ai-sdk/gateway") {
|
||||
// Gateway providerOptions are split across two namespaces:
|
||||
// - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.)
|
||||
// - `<upstream slug>`: provider-specific model options (anthropic/openai/...)
|
||||
// We keep `gateway` as-is and route every other top-level option under the
|
||||
// model-derived upstream slug.
|
||||
const i = model.api.id.indexOf("/")
|
||||
const rawSlug = i > 0 ? model.api.id.slice(0, i) : undefined
|
||||
const slug = rawSlug ? (SLUG_OVERRIDES[rawSlug] ?? rawSlug) : undefined
|
||||
const gateway = options.gateway
|
||||
const rest = Object.fromEntries(Object.entries(options).filter(([k]) => k !== "gateway"))
|
||||
const has = Object.keys(rest).length > 0
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
if (gateway !== undefined) result.gateway = gateway
|
||||
|
||||
if (has) {
|
||||
if (slug) {
|
||||
// Route model-specific options under the provider slug
|
||||
result[slug] = rest
|
||||
} else if (gateway && typeof gateway === "object" && !Array.isArray(gateway)) {
|
||||
result.gateway = { ...gateway, ...rest }
|
||||
} else {
|
||||
result.gateway = rest
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const key = sdkKey(model.api.npm) ?? model.providerID
|
||||
return { [key]: options }
|
||||
}
|
||||
|
||||
@@ -53,15 +53,15 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const term = query.search?.toLowerCase()
|
||||
const sessions: Session.Info[] = []
|
||||
for await (const session of Session.list()) {
|
||||
if (query.directory !== undefined && session.directory !== query.directory) continue
|
||||
if (query.roots && session.parentID) continue
|
||||
if (query.start !== undefined && session.time.updated < query.start) continue
|
||||
if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
|
||||
for await (const session of Session.list({
|
||||
directory: query.directory,
|
||||
roots: query.roots,
|
||||
start: query.start,
|
||||
search: query.search,
|
||||
limit: query.limit,
|
||||
})) {
|
||||
sessions.push(session)
|
||||
if (query.limit !== undefined && sessions.length >= query.limit) break
|
||||
}
|
||||
return c.json(sessions)
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Flag } from "../flag/flag"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Installation } from "../installation"
|
||||
|
||||
import { Database, NotFoundError, eq, and, or, like } from "../storage/db"
|
||||
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
@@ -505,20 +505,38 @@ export namespace Session {
|
||||
},
|
||||
)
|
||||
|
||||
export function* list() {
|
||||
export function* list(input?: {
|
||||
directory?: string
|
||||
roots?: boolean
|
||||
start?: number
|
||||
search?: string
|
||||
limit?: number
|
||||
}) {
|
||||
const project = Instance.project
|
||||
// const rel = path.relative(Instance.worktree, Instance.directory)
|
||||
// const suffix = path.sep + rel
|
||||
const conditions = [eq(SessionTable.project_id, project.id)]
|
||||
|
||||
if (input?.directory) {
|
||||
conditions.push(eq(SessionTable.directory, input.directory))
|
||||
}
|
||||
if (input?.roots) {
|
||||
conditions.push(isNull(SessionTable.parent_id))
|
||||
}
|
||||
if (input?.start) {
|
||||
conditions.push(gte(SessionTable.time_updated, input.start))
|
||||
}
|
||||
if (input?.search) {
|
||||
conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
}
|
||||
|
||||
const limit = input?.limit ?? 100
|
||||
|
||||
const rows = Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionTable.project_id, project.id),
|
||||
// or(eq(SessionTable.directory, Instance.directory), like(SessionTable.directory, `%${suffix}`)),
|
||||
),
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(SessionTable.time_updated))
|
||||
.limit(limit)
|
||||
.all(),
|
||||
)
|
||||
for (const row of rows) {
|
||||
|
||||
@@ -445,6 +445,12 @@ export namespace SessionPrompt {
|
||||
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
|
||||
return undefined
|
||||
})
|
||||
const attachments = result?.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID,
|
||||
messageID: assistantMessage.id,
|
||||
}))
|
||||
await Plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{
|
||||
@@ -467,7 +473,7 @@ export namespace SessionPrompt {
|
||||
title: result.title,
|
||||
metadata: result.metadata,
|
||||
output: result.output,
|
||||
attachments: result.attachments,
|
||||
attachments,
|
||||
time: {
|
||||
...part.state.time,
|
||||
end: Date.now(),
|
||||
@@ -797,6 +803,15 @@ export namespace SessionPrompt {
|
||||
},
|
||||
)
|
||||
const result = await item.execute(args, ctx)
|
||||
const output = {
|
||||
...result,
|
||||
attachments: result.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
})),
|
||||
}
|
||||
await Plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{
|
||||
@@ -805,9 +820,9 @@ export namespace SessionPrompt {
|
||||
callID: ctx.callID,
|
||||
args,
|
||||
},
|
||||
result,
|
||||
output,
|
||||
)
|
||||
return result
|
||||
return output
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -855,16 +870,13 @@ export namespace SessionPrompt {
|
||||
)
|
||||
|
||||
const textParts: string[] = []
|
||||
const attachments: MessageV2.FilePart[] = []
|
||||
const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
|
||||
|
||||
for (const contentItem of result.content) {
|
||||
if (contentItem.type === "text") {
|
||||
textParts.push(contentItem.text)
|
||||
} else if (contentItem.type === "image") {
|
||||
attachments.push({
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: input.session.id,
|
||||
messageID: input.processor.message.id,
|
||||
type: "file",
|
||||
mime: contentItem.mimeType,
|
||||
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
|
||||
@@ -876,9 +888,6 @@ export namespace SessionPrompt {
|
||||
}
|
||||
if (resource.blob) {
|
||||
attachments.push({
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: input.session.id,
|
||||
messageID: input.processor.message.id,
|
||||
type: "file",
|
||||
mime: resource.mimeType ?? "application/octet-stream",
|
||||
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
||||
@@ -1157,6 +1166,7 @@ export namespace SessionPrompt {
|
||||
pieces.push(
|
||||
...result.attachments.map((attachment) => ({
|
||||
...attachment,
|
||||
id: Identifier.ascending("part"),
|
||||
synthetic: true,
|
||||
filename: attachment.filename ?? part.filename,
|
||||
messageID: info.id,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
|
||||
import { fn } from "@/util/fn"
|
||||
import z from "zod"
|
||||
import { Session } from "."
|
||||
@@ -8,16 +6,10 @@ import { MessageV2 } from "./message-v2"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
|
||||
import { Log } from "@/util/log"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Bus } from "@/bus"
|
||||
|
||||
import { LLM } from "./llm"
|
||||
import { Agent } from "@/agent/agent"
|
||||
|
||||
export namespace SessionSummary {
|
||||
const log = Log.create({ service: "session.summary" })
|
||||
|
||||
function unquoteGitPath(input: string) {
|
||||
if (!input.startsWith('"')) return input
|
||||
if (!input.endsWith('"')) return input
|
||||
@@ -117,41 +109,6 @@ export namespace SessionSummary {
|
||||
diffs,
|
||||
}
|
||||
await Session.updateMessage(userMsg)
|
||||
|
||||
const textPart = msgWithParts.parts.find((p) => p.type === "text" && !p.synthetic) as MessageV2.TextPart
|
||||
if (textPart && !userMsg.summary?.title) {
|
||||
const agent = await Agent.get("title")
|
||||
if (!agent) return
|
||||
const stream = await LLM.stream({
|
||||
agent,
|
||||
user: userMsg,
|
||||
tools: {},
|
||||
model: agent.model
|
||||
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
|
||||
: ((await Provider.getSmallModel(userMsg.model.providerID)) ??
|
||||
(await Provider.getModel(userMsg.model.providerID, userMsg.model.modelID))),
|
||||
small: true,
|
||||
messages: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: `
|
||||
The following is the text to summarize:
|
||||
<text>
|
||||
${textPart?.text ?? ""}
|
||||
</text>
|
||||
`,
|
||||
},
|
||||
],
|
||||
abort: new AbortController().signal,
|
||||
sessionID: userMsg.sessionID,
|
||||
system: [],
|
||||
retries: 3,
|
||||
})
|
||||
const result = await stream.text
|
||||
log.info("title", { title: result })
|
||||
userMsg.summary.title = result
|
||||
await Session.updateMessage(userMsg)
|
||||
}
|
||||
}
|
||||
|
||||
export const diff = fn(
|
||||
|
||||
@@ -25,6 +25,7 @@ export const NotFoundError = NamedError.create(
|
||||
const log = Log.create({ service: "db" })
|
||||
|
||||
export namespace Database {
|
||||
export const Path = path.join(Global.Path.data, "opencode.db")
|
||||
type Schema = typeof schema
|
||||
export type Transaction = SQLiteTransaction<"sync", void, Schema>
|
||||
|
||||
@@ -74,6 +75,7 @@ export namespace Database {
|
||||
sqlite.run("PRAGMA busy_timeout = 5000")
|
||||
sqlite.run("PRAGMA cache_size = -64000")
|
||||
sqlite.run("PRAGMA foreign_keys = ON")
|
||||
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
|
||||
const db = drizzle({ client: sqlite, schema })
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ export namespace JsonMigration {
|
||||
sqlite.exec("BEGIN TRANSACTION")
|
||||
|
||||
// Migrate projects first (no FK deps)
|
||||
// Derive all IDs from file paths, not JSON content
|
||||
const projectIds = new Set<string>()
|
||||
const projectValues = [] as any[]
|
||||
for (let i = 0; i < projectFiles.length; i += batchSize) {
|
||||
@@ -161,13 +162,10 @@ export namespace JsonMigration {
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const data = batch[j]
|
||||
if (!data) continue
|
||||
if (!data?.id) {
|
||||
errs.push(`project missing id: ${projectFiles[i + j]}`)
|
||||
continue
|
||||
}
|
||||
projectIds.add(data.id)
|
||||
const id = path.basename(projectFiles[i + j], ".json")
|
||||
projectIds.add(id)
|
||||
projectValues.push({
|
||||
id: data.id,
|
||||
id,
|
||||
worktree: data.worktree ?? "/",
|
||||
vcs: data.vcs,
|
||||
name: data.name ?? undefined,
|
||||
@@ -186,6 +184,9 @@ export namespace JsonMigration {
|
||||
log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) })
|
||||
|
||||
// Migrate sessions (depends on projects)
|
||||
// Derive all IDs from directory/file paths, not JSON content, since earlier
|
||||
// migrations may have moved sessions to new directories without updating the JSON
|
||||
const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file)))
|
||||
const sessionIds = new Set<string>()
|
||||
const sessionValues = [] as any[]
|
||||
for (let i = 0; i < sessionFiles.length; i += batchSize) {
|
||||
@@ -195,18 +196,16 @@ export namespace JsonMigration {
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const data = batch[j]
|
||||
if (!data) continue
|
||||
if (!data?.id || !data?.projectID) {
|
||||
errs.push(`session missing id or projectID: ${sessionFiles[i + j]}`)
|
||||
continue
|
||||
}
|
||||
if (!projectIds.has(data.projectID)) {
|
||||
const id = path.basename(sessionFiles[i + j], ".json")
|
||||
const projectID = sessionProjects[i + j]
|
||||
if (!projectIds.has(projectID)) {
|
||||
orphans.sessions++
|
||||
continue
|
||||
}
|
||||
sessionIds.add(data.id)
|
||||
sessionIds.add(id)
|
||||
sessionValues.push({
|
||||
id: data.id,
|
||||
project_id: data.projectID,
|
||||
id,
|
||||
project_id: projectID,
|
||||
parent_id: data.parentID ?? null,
|
||||
slug: data.slug ?? "",
|
||||
directory: data.directory ?? "",
|
||||
@@ -253,11 +252,7 @@ export namespace JsonMigration {
|
||||
const data = batch[j]
|
||||
if (!data) continue
|
||||
const file = allMessageFiles[i + j]
|
||||
const id = data.id ?? path.basename(file, ".json")
|
||||
if (!id) {
|
||||
errs.push(`message missing id: ${file}`)
|
||||
continue
|
||||
}
|
||||
const id = path.basename(file, ".json")
|
||||
const sessionID = allMessageSessions[i + j]
|
||||
messageSessions.set(id, sessionID)
|
||||
const rest = data
|
||||
@@ -287,12 +282,8 @@ export namespace JsonMigration {
|
||||
const data = batch[j]
|
||||
if (!data) continue
|
||||
const file = partFiles[i + j]
|
||||
const id = data.id ?? path.basename(file, ".json")
|
||||
const messageID = data.messageID ?? path.basename(path.dirname(file))
|
||||
if (!id || !messageID) {
|
||||
errs.push(`part missing id/messageID/sessionID: ${file}`)
|
||||
continue
|
||||
}
|
||||
const id = path.basename(file, ".json")
|
||||
const messageID = path.basename(path.dirname(file))
|
||||
const sessionID = messageSessions.get(messageID)
|
||||
if (!sessionID) {
|
||||
errs.push(`part missing message session: ${file}`)
|
||||
|
||||
@@ -77,6 +77,12 @@ export const BatchTool = Tool.define("batch", async () => {
|
||||
})
|
||||
|
||||
const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
|
||||
const attachments = result.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
}))
|
||||
|
||||
await Session.updatePart({
|
||||
id: partID,
|
||||
@@ -91,7 +97,7 @@ export const BatchTool = Tool.define("batch", async () => {
|
||||
output: result.output,
|
||||
title: result.title,
|
||||
metadata: result.metadata,
|
||||
attachments: result.attachments,
|
||||
attachments,
|
||||
time: {
|
||||
start: callStartTime,
|
||||
end: Date.now(),
|
||||
|
||||
@@ -6,7 +6,6 @@ import { LSP } from "../lsp"
|
||||
import { FileTime } from "../file/time"
|
||||
import DESCRIPTION from "./read.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Identifier } from "../id/id"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { InstructionPrompt } from "../session/instruction"
|
||||
|
||||
@@ -127,9 +126,6 @@ export const ReadTool = Tool.define("read", {
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
type: "file",
|
||||
mime,
|
||||
url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`,
|
||||
|
||||
@@ -94,10 +94,11 @@ export namespace ToolRegistry {
|
||||
async function all(): Promise<Tool.Info[]> {
|
||||
const custom = await state().then((x) => x.custom)
|
||||
const config = await Config.get()
|
||||
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||
|
||||
return [
|
||||
InvalidTool,
|
||||
...(["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) ? [QuestionTool] : []),
|
||||
...(question ? [QuestionTool] : []),
|
||||
BashTool,
|
||||
ReadTool,
|
||||
GlobTool,
|
||||
|
||||
@@ -36,7 +36,7 @@ export namespace Tool {
|
||||
title: string
|
||||
metadata: M
|
||||
output: string
|
||||
attachments?: MessageV2.FilePart[]
|
||||
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
|
||||
}>
|
||||
formatValidationError?(error: z.ZodError): string
|
||||
}>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Tool } from "./tool"
|
||||
import TurndownService from "turndown"
|
||||
import DESCRIPTION from "./webfetch.txt"
|
||||
import { abortAfterAny } from "../util/abort"
|
||||
import { Identifier } from "../id/id"
|
||||
|
||||
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
||||
@@ -103,9 +102,6 @@ export const WebFetchTool = Tool.define("webfetch", {
|
||||
metadata: {},
|
||||
attachments: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
type: "file",
|
||||
mime,
|
||||
url: `data:${mime};base64,${base64Content}`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user