Compare commits

..

36 Commits

Author SHA1 Message Date
Dax Raad
b01ed950de feat: add test fields to package.json 2026-02-17 07:18:23 -05:00
James Long
d93cefd47a fix(website): fix site in safari 18 (#13894) 2026-02-16 17:39:28 -05:00
Aiden Cline
a580fb47d2 tweak: drop ids from attachments in tools, assign them in prompt.ts instead (#13890) 2026-02-16 14:59:57 -06:00
ImmuneFOMO
9d3c81a683 feat(acp): add opt-in flag for question tool (#13562)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-02-16 14:20:41 -06:00
Zhiyuan Zheng
86e545a23e fix(opencode): ACP sessions never get LLM-generated titles (#13095) 2026-02-16 14:16:17 -06:00
Ariane Emory
b0afdf6ea4 feat(cli): add session delete command (#13571) 2026-02-16 14:15:34 -06:00
opencode
d8c25bfeb4 release: v1.2.6 2026-02-16 19:57:09 +00:00
Robert Schadek
160ba295a8 feat(opencode): add dfmt formatter support for D language files (#13867) 2026-02-16 13:14:35 -06:00
OpeOginni
16332a8583 fix(tui): make use of server dir path for file references in prompts (#13781) 2026-02-16 13:14:08 -06:00
Aiden Cline
ae6e85b2a4 ignore: rm random comment on opencode.jsonc 2026-02-16 13:09:39 -06:00
Dax
fdad823edc feat(cli): add db migrate command for JSON to SQLite migration (#13874) 2026-02-16 19:05:21 +00:00
Ryan Vogel
5cc1d6097e feat(cli): add --continue and --fork flags to attach command (#13879) 2026-02-16 13:45:00 -05:00
opencode-agent[bot]
8c1af9b445 chore: update nix node_modules hashes 2026-02-16 17:38:43 +00:00
Vladimir Glafirov
ef979ccfa8 fix: bump GitLab provider and auth plugin for mid-session token refresh (#13850) 2026-02-16 10:01:17 -06:00
Imanol Maiztegui
bb30e06855 fix (tui): Inaccurate tips (#13845) 2026-02-16 09:08:04 -05:00
Adam
b055f973df chore: cleanup 2026-02-16 07:58:18 -06:00
Rafi Khardalian
45fa5e7199 fix(core): remove unnecessary per-message title LLM calls (#13804) 2026-02-16 06:04:20 -06:00
Chujiang
3ebf27aab9 fix(docs): correct critical translation errors in Russian zen page (#13830) 2026-02-16 06:02:48 -06:00
Aiden Cline
1d041c8861 fix: google vertex var priority (#13816) 2026-02-16 02:41:52 -06:00
opencode-agent[bot]
089ab9defa chore: generate 2026-02-16 08:32:34 +00:00
Jhin Lee
f7708efa5b feat: add openai-compatible endpoint support for google-vertex provider (#10303)
Co-authored-by: BlueT - Matthew Lien - 練喆明 <BlueT@BlueT.org>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-02-16 02:31:48 -06:00
bnema
60807846a9 fix(desktop): normalize Linux Wayland/X11 backend and decoration policy (#13143)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-02-16 13:24:28 +08:00
dpuyosa
afd0716cbd feat(opencode): Add Venice support in temperature, topP, topK and smallOption (#13553) 2026-02-15 22:24:24 -06:00
Brendan Allan
920255e8c6 desktop: use process-wrap instead of manual job object (#13431) 2026-02-16 04:14:24 +00:00
opencode-agent[bot]
21e0778002 chore: generate 2026-02-15 22:31:40 +00:00
Pan Kaixin
d9363da9ee fix(website): correct zh-CN translation of proprietary terms in zen.mdx (#13734)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-02-15 16:30:47 -06:00
Salam Elbilig
9b23130ac4 feat(opencode): add cljfmt formatter support for Clojure files (#13426) 2026-02-15 15:21:57 -06:00
opencode
62a24c2dda release: v1.2.5 2026-02-15 18:49:52 +00:00
Alex Yaroshuk
3a3aa300bb feat(app): localize "free usage exceeded" error & "Add credits" clickable link (#13652) 2026-02-15 10:40:09 -06:00
Shane Bishop
cf50a289db fix(desktop): issue viewing new files opened from the file tree (#13689) 2026-02-15 09:48:40 -06:00
Shoubhit Dash
3c85cf4fac fix(app): only navigate prompt history at input boundaries (#13690) 2026-02-15 07:47:19 -06:00
Filip
878ddc6a0a fix(app): keybind [shift+tab] (#13695) 2026-02-15 07:46:56 -06:00
Denys
3761121728 docs: add Ukrainian README translation (#13697) 2026-02-15 07:46:19 -06:00
zerone0x
3aaa34be1e fix(desktop): focus window after update/relaunch (#13701) 2026-02-15 07:45:34 -06:00
Brandon Julio Thenaro
985c2a3d15 feat: Add GeistMono Nerd Font to available mono font options (#13720) 2026-02-15 07:44:21 -06:00
Aiden Cline
eb553f53ac fix: ensure sqlite migration logs to stderr instead of stdout (#13691) 2026-02-15 00:41:16 -06:00
144 changed files with 1726 additions and 914 deletions

View File

@@ -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

View File

@@ -1,9 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
"references": ["git@github.com:Effect-TS/effect.git"],
"provider": {
"opencode": {
"options": {},

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

139
README.uk.md Normal file
View 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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"version": "1.2.6",
"bin": {
"opencode": "./bin/opencode",
},
@@ -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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"version": "1.2.6",
"dependencies": {
"zod": "catalog:",
},
@@ -466,7 +466,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.4",
"version": "1.2.6",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -989,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=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-5pgd2xuvIIkTbIOGIdK5MIXo6O9qRpvk1RKQZ1e1R+8=",
"aarch64-linux": "sha256-FZiHwihM4b82ipQ9XfW08X+sd5CvZhx/+pU/8X1zsns=",
"aarch64-darwin": "sha256-iZv0w1NthV53pY5uvuf3JlI14GeKmCu7WHwGSRdEQeM=",
"x86_64-darwin": "sha256-c3Zm3P1goFPgg3vNAZPMFOhHX/gyTmsCN/PKbGO/v0E="
"x86_64-linux": "sha256-C3WIEER2XgzO85wk2sp3BzQ6dknW026zslD8nKZjo2U=",
"aarch64-linux": "sha256-+tTJHZMZ/+8fAjI/1fUTuca8J2MZfB+5vhBoZ7jgqcE=",
"aarch64-darwin": "sha256-vS82puFGBBToxyIBa8Zi0KLKdJYr64T6HZL2rL32mH8=",
"x86_64-darwin": "sha256-Tr8JMTCxV6WVt3dXV7iq3PNCm2Cn+RXAbU9+o7pKKV0="
}
}

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.4",
"version": "1.2.6",
"description": "",
"type": "module",
"exports": {

View File

@@ -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

View File

@@ -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)
})
})

View File

@@ -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 {

View File

@@ -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]

View File

@@ -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()

View File

@@ -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) {

View File

@@ -557,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",

View File

@@ -563,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",

View File

@@ -631,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",

View File

@@ -627,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",

View File

@@ -572,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",

View File

@@ -632,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",

View File

@@ -635,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",

View File

@@ -571,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",

View File

@@ -561,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",

View File

@@ -562,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",

View File

@@ -634,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",

View File

@@ -562,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",

View File

@@ -632,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",

View File

@@ -626,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",

View File

@@ -623,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",

View File

@@ -621,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",

View File

@@ -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,
})

View File

@@ -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)

View File

@@ -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)

View File

@@ -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),
)
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.4",
"version": "1.2.6",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -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;
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.4",
"version": "1.2.6",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.4",
"version": "1.2.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.4",
"version": "1.2.6",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.4",
"version": "1.2.6",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -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"

View File

@@ -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" }

View File

@@ -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(())

View File

@@ -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());
}
}

View File

@@ -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 });
}

View 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));
}
}

View File

@@ -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")

View File

@@ -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() {

View File

@@ -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},
};

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.4",
"version": "1.2.6",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.4"
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.4/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.4/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.4/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.4/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.4/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.4",
"version": "1.2.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.4",
"version": "1.2.6",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -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",

View File

@@ -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

View File

@@ -21,7 +21,6 @@ export class ACPSessionManager {
const session = await this.sdk.session
.create(
{
title: `ACP Session ${crypto.randomUUID()}`,
directory: cwd,
},
{ throwOnError: true },

View File

@@ -19,7 +19,6 @@ import { Global } from "@/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Reference } from "@/reference"
export namespace Agent {
export const Info = z
@@ -74,17 +73,6 @@ export namespace Agent {
})
const user = PermissionNext.fromConfig(cfg.permission ?? {})
let explorePrompt = PROMPT_EXPLORE
if (cfg.references?.length) {
const refs = cfg.references.map((r) => Reference.parse(r))
const fresh = await Promise.all(refs.map((r) => Reference.ensureFresh(r)))
const valid = fresh.filter(Boolean) as Reference.Info[]
if (valid.length > 0) {
explorePrompt +=
"\n\n<references>\n" + valid.map((r) => `- ${r.url} -> ${r.path}`).join("\n") + "\n</references>"
}
}
const result: Record<string, Info> = {
build: {
name: "build",
@@ -155,17 +143,12 @@ export namespace Agent {
read: "allow",
external_directory: {
[Truncate.GLOB]: "allow",
[path.join(Global.Path.reference, "*")]: "allow",
},
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.${
cfg.references?.length
? `\n\nAlways use this to answer questions about the following referenced projects:\n${cfg.references.map((r) => `- ${r}`).join("\n")}`
: ""
}`,
prompt: explorePrompt,
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
mode: "subagent",
native: true,

View File

@@ -15,13 +15,4 @@ Guidelines:
- For clear communication, avoid using emojis
- Do not create any files, or run bash commands that modify the user's system state in any way
Referenced projects:
When configured, you also have access to referenced projects - external codebases that may contain relevant code or patterns. Use these when:
- The user asks about code not found in the main project
- You need to understand how a library or dependency works
- The user mentions an external repository or package
- Searching for patterns across multiple codebases
Search referenced projects by using their absolute paths (provided in a <references> tag) with Glob, Grep, and Read tools.
Complete the user's search request efficiently and report your findings clearly.

View File

@@ -4,6 +4,8 @@ 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]",
@@ -58,11 +60,59 @@ const PathCommand = cmd({
},
})
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).demandCommand()
return yargs.command(QueryCommand).command(PathCommand).command(MigrateCommand).demandCommand()
},
handler: () => {},
})

View File

@@ -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",

View File

@@ -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,
})

View File

@@ -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("/")) {

View File

@@ -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",
]

View File

@@ -1192,10 +1192,6 @@ export namespace Config {
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
})
.optional(),
references: z
.array(z.string())
.optional()
.describe("Git repositories or local paths to reference from subagents"),
})
.strict()
.meta({

View File

@@ -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")

View File

@@ -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
},
}

View File

@@ -20,7 +20,6 @@ export namespace Global {
bin: path.join(data, "bin"),
log: path.join(data, "log"),
cache,
reference: path.join(cache, "references"),
config,
state,
}
@@ -32,7 +31,6 @@ await Promise.all([
fs.mkdir(Global.Path.state, { recursive: true }),
fs.mkdir(Global.Path.log, { recursive: true }),
fs.mkdir(Global.Path.bin, { recursive: true }),
fs.mkdir(Global.Path.reference, { recursive: true }),
])
const CACHE_VERSION = "21"

View File

@@ -82,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) => {
@@ -99,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())
@@ -190,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 {

View File

@@ -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"] = {

View File

@@ -298,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
@@ -310,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
@@ -319,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
@@ -802,6 +802,11 @@ export namespace ProviderTransform {
}
return { reasoningEffort: "minimal" }
}
if (model.providerID === "venice") {
return { veniceParameters: { disableThinking: true } }
}
return {}
}

View File

@@ -1,131 +0,0 @@
import path from "path"
import { mkdir, stat } from "fs/promises"
import { createHash } from "crypto"
import { Global } from "../global"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { git } from "../util/git"
import { Instance } from "../project/instance"
export namespace Reference {
const log = Log.create({ service: "reference" })
const STALE_THRESHOLD_MS = 60 * 60 * 1000
export interface Info {
url: string
path: string
branch?: string
type: "git" | "local"
}
function hashUrl(url: string): string {
return createHash("sha256").update(url).digest("hex").slice(0, 16)
}
export function parse(url: string): Info {
if (url.startsWith("/") || url.startsWith("~") || url.startsWith(".")) {
const resolved = url.startsWith("~")
? path.join(Global.Path.home, url.slice(1))
: url.startsWith(".")
? path.resolve(Instance.worktree, url)
: url
return {
url,
path: resolved,
type: "local",
}
}
const branchMatch = url.match(/^(.+)#(.+)$/)
const gitUrl = branchMatch ? branchMatch[1] : url
const branch = branchMatch ? branchMatch[2] : undefined
return {
url: gitUrl,
path: path.join(Global.Path.reference, hashUrl(gitUrl)),
branch,
type: "git",
}
}
export async function isStale(ref: Info): Promise<boolean> {
if (ref.type === "local") return false
const fetchHead = path.join(ref.path, ".git", "FETCH_HEAD")
const s = await stat(fetchHead).catch(() => null)
if (!s) return true
return Date.now() - s.mtime.getTime() > STALE_THRESHOLD_MS
}
export async function fetch(ref: Info): Promise<boolean> {
if (ref.type === "local") {
const exists = await stat(ref.path).catch(() => null)
if (!exists?.isDirectory()) {
log.error("local reference not found", { path: ref.path })
return false
}
return true
}
await mkdir(path.dirname(ref.path), { recursive: true })
const isCloned = await stat(path.join(ref.path, ".git")).catch(() => null)
if (!isCloned) {
log.info("cloning reference", { url: ref.url, branch: ref.branch })
const args = ["clone", "--depth", "1"]
if (ref.branch) {
args.push("--branch", ref.branch)
}
args.push(ref.url, ref.path)
const result = await git(args, { cwd: Global.Path.reference })
if (result.exitCode !== 0) {
log.error("failed to clone", { url: ref.url, stderr: result.stderr.toString() })
return false
}
return true
}
log.info("fetching reference", { url: ref.url })
const fetchResult = await git(["fetch"], { cwd: ref.path })
if (fetchResult.exitCode !== 0) {
log.warn("failed to fetch, using cached", { url: ref.url })
return true
}
if (ref.branch) {
const checkoutResult = await git(["checkout", ref.branch], { cwd: ref.path })
if (checkoutResult.exitCode !== 0) {
log.warn("failed to checkout branch, using current", { url: ref.url, branch: ref.branch })
}
}
return true
}
export async function ensureFresh(ref: Info): Promise<Info | null> {
if (await isStale(ref)) {
const success = await fetch(ref)
if (!success && ref.type === "git") {
const exists = await stat(ref.path).catch(() => null)
if (!exists) return null
}
}
return ref
}
export async function list(): Promise<Info[]> {
const cfg = await Config.get()
const urls = cfg.references ?? []
return urls.map(parse)
}
export async function directories(): Promise<string[]> {
const refs = await list()
const fresh = await Promise.all(refs.map(ensureFresh))
return fresh.filter(Boolean).map((r) => r!.path)
}
}

View File

@@ -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,

View File

@@ -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(

View 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(),

View File

@@ -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")}`,

View File

@@ -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,

View File

@@ -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
}>

View File

@@ -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}`,

View File

@@ -2127,3 +2127,94 @@ test("custom model with variants enabled and disabled", async () => {
},
})
})
test("Google Vertex: retains baseURL for custom proxy", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"vertex-proxy": {
name: "Vertex Proxy",
npm: "@ai-sdk/google-vertex",
api: "https://my-proxy.com/v1",
env: ["GOOGLE_APPLICATION_CREDENTIALS"], // Mock env var requirement
models: {
"gemini-pro": {
name: "Gemini Pro",
tool_call: true,
},
},
options: {
project: "test-project",
location: "us-central1",
baseURL: "https://my-proxy.com/v1", // Should be retained
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["vertex-proxy"]).toBeDefined()
expect(providers["vertex-proxy"].options.baseURL).toBe("https://my-proxy.com/v1")
},
})
})
test("Google Vertex: supports OpenAI compatible models", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"vertex-openai": {
name: "Vertex OpenAI",
npm: "@ai-sdk/google-vertex",
env: ["GOOGLE_APPLICATION_CREDENTIALS"],
models: {
"gpt-4": {
name: "GPT-4",
provider: {
npm: "@ai-sdk/openai-compatible",
api: "https://api.openai.com/v1",
},
},
},
options: {
project: "test-project",
location: "us-central1",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
},
fn: async () => {
const providers = await Provider.list()
const model = providers["vertex-openai"].models["gpt-4"]
expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai-compatible")
},
})
})

View File

@@ -349,6 +349,9 @@ describe("tool.read truncation", () => {
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
},
})
})
@@ -363,6 +366,9 @@ describe("tool.read truncation", () => {
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0].type).toBe("file")
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
},
})
})

View File

@@ -46,6 +46,9 @@ describe("tool.webfetch", () => {
expect(result.attachments?.[0].type).toBe("file")
expect(result.attachments?.[0].mime).toBe("image/png")
expect(result.attachments?.[0].url.startsWith("data:image/png;base64,")).toBe(true)
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
},
})
},

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.4",
"version": "1.2.6",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.4",
"version": "1.2.6",
"type": "module",
"license": "MIT",
"scripts": {

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