Merge branch 'dev' into feat/canceled-prompts-in-history

This commit is contained in:
Ariane Emory
2026-02-15 12:27:51 -05:00
70 changed files with 717 additions and 215 deletions

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.2",
"version": "1.2.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.2",
"version": "1.2.4",
"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.2",
"version": "1.2.4",
"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.2",
"version": "1.2.4",
"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.2",
"version": "1.2.4",
"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.2",
"version": "1.2.4",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -215,7 +215,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.2",
"version": "1.2.4",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -244,7 +244,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.2",
"version": "1.2.4",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -260,7 +260,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.2",
"version": "1.2.4",
"bin": {
"opencode": "./bin/opencode",
},
@@ -369,7 +369,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.2",
"version": "1.2.4",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -389,7 +389,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.2",
"version": "1.2.4",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -400,7 +400,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.2",
"version": "1.2.4",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -413,7 +413,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.2",
"version": "1.2.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -455,7 +455,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.2",
"version": "1.2.4",
"dependencies": {
"zod": "catalog:",
},
@@ -466,7 +466,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.2",
"version": "1.2.4",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",

View File

@@ -30,6 +30,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
export const projectClearNotificationsSelector = (slug: string) =>
`[data-action="project-clear-notifications"][data-project="${slug}"]`
export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`

View File

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

View File

@@ -21,6 +21,8 @@ import {
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
const MAX_DEPTH = 128
function pathToFileUrl(filepath: string): string {
return `file://${encodeFilePath(filepath)}`
}
@@ -260,12 +262,20 @@ export default function FileTree(props: {
_marks?: Set<string>
_deeps?: Map<string, number>
_kinds?: ReadonlyMap<string, Kind>
_chain?: readonly string[]
}) {
const file = useFile()
const level = props.level ?? 0
const draggable = () => props.draggable ?? true
const tooltip = () => props.tooltip ?? true
const key = (p: string) =>
file
.normalize(p)
.replace(/[\\/]+$/, "")
.replaceAll("\\", "/")
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
const filter = createMemo(() => {
if (props._filter) return props._filter
@@ -307,23 +317,45 @@ export default function FileTree(props: {
const out = new Map<string, number>()
const visit = (dir: string, lvl: number): number => {
const expanded = file.tree.state(dir)?.expanded ?? false
if (!expanded) return -1
const root = props.path
if (!(file.tree.state(root)?.expanded ?? false)) return out
const nodes = file.tree.children(dir)
const max = nodes.reduce((max, node) => {
if (node.type !== "directory") return max
const open = file.tree.state(node.path)?.expanded ?? false
if (!open) return max
return Math.max(max, visit(node.path, lvl + 1))
}, lvl)
const seen = new Set<string>()
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
out.set(dir, max)
return max
const push = (dir: string, lvl: number) => {
const id = key(dir)
if (seen.has(id)) return
seen.add(id)
const kids = file.tree
.children(dir)
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
.map((node) => node.path)
stack.push({ dir, lvl, i: 0, kids, max: lvl })
}
push(root, level - 1)
while (stack.length > 0) {
const top = stack[stack.length - 1]!
if (top.i < top.kids.length) {
const next = top.kids[top.i]!
top.i++
push(next, top.lvl + 1)
continue
}
out.set(top.dir, top.max)
stack.pop()
const parent = stack[stack.length - 1]
if (!parent) continue
parent.max = Math.max(parent.max, top.max)
}
visit(props.path, level - 1)
return out
})
@@ -459,21 +491,27 @@ export default function FileTree(props: {
}}
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
/>
<FileTree
path={node.path}
level={level + 1}
allowed={props.allowed}
modified={props.modified}
kinds={props.kinds}
active={props.active}
draggable={props.draggable}
tooltip={props.tooltip}
onFileClick={props.onFileClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
_kinds={kinds()}
/>
<Show
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
>
<FileTree
path={node.path}
level={level + 1}
allowed={props.allowed}
modified={props.modified}
kinds={props.kinds}
active={props.active}
draggable={props.draggable}
tooltip={props.tooltip}
onFileClick={props.onFileClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
_kinds={kinds()}
_chain={chain}
/>
</Show>
</Collapsible.Content>
</Collapsible>
</Match>

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

@@ -509,6 +509,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
"sidebar.project.recentSessions": "الجلسات الحديثة",
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
"sidebar.project.clearNotifications": "مسح الإشعارات",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "سطح المكتب",
"settings.section.server": "الخادم",
@@ -556,6 +557,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "تنبيه 01",
"sound.option.alert02": "تنبيه 02",
"sound.option.alert03": "تنبيه 03",

View File

@@ -515,6 +515,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Conecte qualquer provedor para usar modelos, incluindo Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessões recentes",
"sidebar.project.viewAllSessions": "Ver todas as sessões",
"sidebar.project.clearNotifications": "Limpar notificações",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
"settings.section.server": "Servidor",
@@ -562,6 +563,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alerta 01",
"sound.option.alert02": "Alerta 02",
"sound.option.alert03": "Alerta 03",

View File

@@ -576,6 +576,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Poveži bilo kojeg provajdera da koristiš modele, npr. Claude, GPT, Gemini itd.",
"sidebar.project.recentSessions": "Nedavne sesije",
"sidebar.project.viewAllSessions": "Prikaži sve sesije",
"sidebar.project.clearNotifications": "Očisti obavijesti",
"app.name.desktop": "OpenCode Desktop",
@@ -630,6 +631,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Upozorenje 01",
"sound.option.alert02": "Upozorenje 02",
"sound.option.alert03": "Upozorenje 03",

View File

@@ -572,6 +572,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Forbind enhver udbyder for at bruge modeller, inkl. Claude, GPT, Gemini osv.",
"sidebar.project.recentSessions": "Seneste sessioner",
"sidebar.project.viewAllSessions": "Vis alle sessioner",
"sidebar.project.clearNotifications": "Ryd notifikationer",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
@@ -626,6 +627,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alarm 01",
"sound.option.alert02": "Alarm 02",
"sound.option.alert03": "Alarm 03",

View File

@@ -524,6 +524,7 @@ export const dict = {
"Verbinden Sie einen beliebigen Anbieter, um Modelle wie Claude, GPT, Gemini usw. zu nutzen.",
"sidebar.project.recentSessions": "Letzte Sitzungen",
"sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen",
"sidebar.project.clearNotifications": "Benachrichtigungen löschen",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
@@ -571,6 +572,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alarm 01",
"sound.option.alert02": "Alarm 02",
"sound.option.alert03": "Alarm 03",

View File

@@ -577,6 +577,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Connect any provider to use models, inc. Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Recent sessions",
"sidebar.project.viewAllSessions": "View all sessions",
"sidebar.project.clearNotifications": "Clear notifications",
"app.name.desktop": "OpenCode Desktop",
@@ -631,6 +632,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",

View File

@@ -579,6 +579,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Conecta cualquier proveedor para usar modelos, inc. Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sesiones recientes",
"sidebar.project.viewAllSessions": "Ver todas las sesiones",
"sidebar.project.clearNotifications": "Borrar notificaciones",
"app.name.desktop": "OpenCode Desktop",
@@ -634,6 +635,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alerta 01",
"sound.option.alert02": "Alerta 02",
"sound.option.alert03": "Alerta 03",

View File

@@ -523,6 +523,7 @@ export const dict = {
"Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessions récentes",
"sidebar.project.viewAllSessions": "Voir toutes les sessions",
"sidebar.project.clearNotifications": "Effacer les notifications",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Bureau",
"settings.section.server": "Serveur",
@@ -570,6 +571,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alerte 01",
"sound.option.alert02": "Alerte 02",
"sound.option.alert03": "Alerte 03",

View File

@@ -513,6 +513,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "プロバイダーを接続して、Claude、GPT、Geminiなどのモデルを使用できます。",
"sidebar.project.recentSessions": "最近のセッション",
"sidebar.project.viewAllSessions": "すべてのセッションを表示",
"sidebar.project.clearNotifications": "通知をクリア",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "デスクトップ",
"settings.section.server": "サーバー",
@@ -560,6 +561,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "アラート 01",
"sound.option.alert02": "アラート 02",
"sound.option.alert03": "アラート 03",

View File

@@ -514,6 +514,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Claude, GPT, Gemini 등을 포함한 모델을 사용하려면 공급자를 연결하세요.",
"sidebar.project.recentSessions": "최근 세션",
"sidebar.project.viewAllSessions": "모든 세션 보기",
"sidebar.project.clearNotifications": "알림 지우기",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "데스크톱",
"settings.section.server": "서버",
@@ -561,6 +562,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "알림 01",
"sound.option.alert02": "알림 02",
"sound.option.alert03": "알림 03",

View File

@@ -579,6 +579,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Koble til en leverandør for å bruke modeller, inkl. Claude, GPT, Gemini osv.",
"sidebar.project.recentSessions": "Nylige sesjoner",
"sidebar.project.viewAllSessions": "Vis alle sesjoner",
"sidebar.project.clearNotifications": "Fjern varsler",
"app.name.desktop": "OpenCode Desktop",
@@ -633,6 +634,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Varsel 01",
"sound.option.alert02": "Varsel 02",
"sound.option.alert03": "Varsel 03",

View File

@@ -514,6 +514,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Połącz dowolnego dostawcę, aby używać modeli, w tym Claude, GPT, Gemini itp.",
"sidebar.project.recentSessions": "Ostatnie sesje",
"sidebar.project.viewAllSessions": "Zobacz wszystkie sesje",
"sidebar.project.clearNotifications": "Wyczyść powiadomienia",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Pulpit",
"settings.section.server": "Serwer",
@@ -561,6 +562,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",

View File

@@ -578,6 +578,7 @@ export const dict = {
"Подключите любого провайдера для использования моделей, включая Claude, GPT, Gemini и др.",
"sidebar.project.recentSessions": "Недавние сессии",
"sidebar.project.viewAllSessions": "Посмотреть все сессии",
"sidebar.project.clearNotifications": "Очистить уведомления",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Приложение",
@@ -631,6 +632,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",

View File

@@ -571,6 +571,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "เชื่อมต่อผู้ให้บริการใด ๆ เพื่อใช้โมเดล รวมถึง Claude, GPT, Gemini ฯลฯ",
"sidebar.project.recentSessions": "เซสชันล่าสุด",
"sidebar.project.viewAllSessions": "ดูเซสชันทั้งหมด",
"sidebar.project.clearNotifications": "ล้างการแจ้งเตือน",
"app.name.desktop": "OpenCode Desktop",
@@ -625,6 +626,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "เสียงเตือน 01",
"sound.option.alert02": "เสียงเตือน 02",
"sound.option.alert03": "เสียงเตือน 03",

View File

@@ -569,6 +569,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "连接任意提供商即可使用更多模型,如 Claude、GPT、Gemini 等。",
"sidebar.project.recentSessions": "最近会话",
"sidebar.project.viewAllSessions": "查看全部会话",
"sidebar.project.clearNotifications": "清除通知",
"app.name.desktop": "OpenCode Desktop",
@@ -622,6 +623,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "警报 01",
"sound.option.alert02": "警报 02",

View File

@@ -567,6 +567,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "連線任意提供者即可使用更多模型,如 Claude、GPT、Gemini 等。",
"sidebar.project.recentSessions": "最近工作階段",
"sidebar.project.viewAllSessions": "查看全部工作階段",
"sidebar.project.clearNotifications": "清除通知",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "桌面",
@@ -620,6 +621,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "警報 01",
"sound.option.alert02": "警報 02",
"sound.option.alert03": "警報 03",

View File

@@ -1692,6 +1692,13 @@ export default function Layout(props: ParentProps) {
})
const projectId = createMemo(() => panelProps.project?.id ?? "")
const workspaces = createMemo(() => workspaceIds(panelProps.project))
const unseenCount = createMemo(() =>
workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
const clearNotifications = () =>
workspaces()
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
const workspacesEnabled = createMemo(() => {
const project = panelProps.project
if (!project) return false
@@ -1769,6 +1776,16 @@ export default function Layout(props: ParentProps) {
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(p().worktree)}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"

View File

@@ -10,6 +10,7 @@ import { createSortable } from "@thisbeyond/solid-dnd"
import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
@@ -59,6 +60,7 @@ const ProjectTile = (props: {
selected: Accessor<boolean>
active: Accessor<boolean>
overlay: Accessor<boolean>
dirs: Accessor<string[]>
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
@@ -70,73 +72,94 @@ const ProjectTile = (props: {
setMenu: (value: boolean) => void
setOpen: (value: boolean) => void
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
<ContextMenu
modal={!props.sidebarHovering()}
onOpenChange={(value) => {
props.setMenu(value)
if (value) props.setOpen(false)
}}
>
<ContextMenu.Trigger
as="button"
type="button"
aria-label={displayName(props.project)}
data-action="project-switch"
data-project={base64Encode(props.project.worktree)}
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!props.selected() && !props.active(),
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}): JSX.Element => {
const notification = useNotification()
const unseenCount = createMemo(() =>
props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
const clear = () =>
props
.dirs()
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
return (
<ContextMenu
modal={!props.sidebarHovering()}
onOpenChange={(value) => {
props.setMenu(value)
if (value) props.setOpen(false)
}}
onMouseEnter={(event: MouseEvent) => {
if (!props.overlay()) return
props.onProjectMouseEnter(props.project.worktree, event)
}}
onMouseLeave={() => {
if (!props.overlay()) return
props.onProjectMouseLeave(props.project.worktree)
}}
onFocus={() => {
if (!props.overlay()) return
props.onProjectFocus(props.project.worktree)
}}
onClick={() => props.navigateToProject(props.project.worktree)}
onBlur={() => props.setOpen(false)}
>
<ProjectIcon project={props.project} notify />
</ContextMenu.Trigger>
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
onSelect={() => props.toggleProjectWorkspaces(props.project)}
>
<ContextMenu.ItemLabel>
{props.workspacesEnabled(props.project)
? props.language.t("sidebar.workspaces.disable")
: props.language.t("sidebar.workspaces.enable")}
</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
data-action="project-close-menu"
data-project={base64Encode(props.project.worktree)}
onSelect={() => props.closeProject(props.project.worktree)}
>
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
<ContextMenu.Trigger
as="button"
type="button"
aria-label={displayName(props.project)}
data-action="project-switch"
data-project={base64Encode(props.project.worktree)}
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!props.selected() && !props.active(),
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}}
onMouseEnter={(event: MouseEvent) => {
if (!props.overlay()) return
props.onProjectMouseEnter(props.project.worktree, event)
}}
onMouseLeave={() => {
if (!props.overlay()) return
props.onProjectMouseLeave(props.project.worktree)
}}
onFocus={() => {
if (!props.overlay()) return
props.onProjectFocus(props.project.worktree)
}}
onClick={() => props.navigateToProject(props.project.worktree)}
onBlur={() => props.setOpen(false)}
>
<ProjectIcon project={props.project} notify />
</ContextMenu.Trigger>
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
onSelect={() => props.toggleProjectWorkspaces(props.project)}
>
<ContextMenu.ItemLabel>
{props.workspacesEnabled(props.project)
? props.language.t("sidebar.workspaces.disable")
: props.language.t("sidebar.workspaces.enable")}
</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(props.project.worktree)}
disabled={unseenCount() === 0}
onSelect={clear}
>
<ContextMenu.ItemLabel>{props.language.t("sidebar.project.clearNotifications")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
data-action="project-close-menu"
data-project={base64Encode(props.project.worktree)}
onSelect={() => props.closeProject(props.project.worktree)}
>
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}
const ProjectPreviewPanel = (props: {
project: LocalProject
@@ -254,6 +277,7 @@ export const SortableProject = (props: {
)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [open, setOpen] = createSignal(false)
const [menu, setMenu] = createSignal(false)
@@ -304,6 +328,7 @@ export const SortableProject = (props: {
selected={selected}
active={active}
overlay={overlay}
dirs={dirs}
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
onProjectFocus={props.ctx.onProjectFocus}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.2",
"version": "1.2.4",
"$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.2",
"version": "1.2.4",
"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.2",
"version": "1.2.4",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -22,6 +22,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));
}
@@ -50,6 +52,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)]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.2",
"version": "1.2.4",
"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.2"
version = "1.2.4"
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.2/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.4/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.2/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.4/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.2/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.4/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.2/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.4/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.2/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.4/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.2",
"version": "1.2.4",
"$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.2",
"version": "1.2.4",
"name": "opencode",
"type": "module",
"license": "MIT",

View File

@@ -0,0 +1,68 @@
import type { Argv } from "yargs"
import { spawn } from "child_process"
import { Database } from "../../storage/db"
import { Database as BunDatabase } from "bun:sqlite"
import { UI } from "../ui"
import { cmd } from "./cmd"
const QueryCommand = cmd({
command: "$0 [query]",
describe: "open an interactive sqlite3 shell or run a query",
builder: (yargs: Argv) => {
return yargs
.positional("query", {
type: "string",
describe: "SQL query to execute",
})
.option("format", {
type: "string",
choices: ["json", "tsv"],
default: "tsv",
describe: "Output format",
})
},
handler: async (args: { query?: string; format: string }) => {
const query = args.query as string | undefined
if (query) {
const db = new BunDatabase(Database.Path, { readonly: true })
try {
const result = db.query(query).all() as Record<string, unknown>[]
if (args.format === "json") {
console.log(JSON.stringify(result, null, 2))
} else if (result.length > 0) {
const keys = Object.keys(result[0])
console.log(keys.join("\t"))
for (const row of result) {
console.log(keys.map((k) => row[k]).join("\t"))
}
}
} catch (err) {
UI.error(err instanceof Error ? err.message : String(err))
process.exit(1)
}
db.close()
return
}
const child = spawn("sqlite3", [Database.Path], {
stdio: "inherit",
})
await new Promise((resolve) => child.on("close", resolve))
},
})
const PathCommand = cmd({
command: "path",
describe: "print the database path",
handler: () => {
console.log(Database.Path)
},
})
export const DbCommand = cmd({
command: "db",
describe: "database tools",
builder: (yargs: Argv) => {
return yargs.command(QueryCommand).command(PathCommand).demandCommand()
},
handler: () => {},
})

View File

@@ -26,6 +26,7 @@ import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
import { DbCommand } from "./cli/cmd/db"
import path from "path"
import { Global } from "./global"
import { JsonMigration } from "./storage/json-migration"
@@ -81,14 +82,14 @@ const cli = yargs(hideBin(process.argv))
const marker = path.join(Global.Path.data, "opencode.db")
if (!(await Bun.file(marker).exists())) {
console.log("Performing one time database migration, may take a few minutes...")
const tty = process.stdout.isTTY
const tty = process.stderr.isTTY
process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)
const width = 36
const orange = "\x1b[38;5;214m"
const muted = "\x1b[0;2m"
const reset = "\x1b[0m"
let last = -1
if (tty) process.stdout.write("\x1b[?25l")
if (tty) process.stderr.write("\x1b[?25l")
try {
await JsonMigration.run(Database.Client().$client, {
progress: (event) => {
@@ -98,22 +99,22 @@ const cli = yargs(hideBin(process.argv))
if (tty) {
const fill = Math.round((percent / 100) * width)
const bar = `${"■".repeat(fill)}${"・".repeat(width - fill)}`
process.stdout.write(
process.stderr.write(
`\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.label.padEnd(12)} ${event.current}/${event.total}${reset}`,
)
if (event.current === event.total) process.stdout.write("\n")
if (event.current === event.total) process.stderr.write("\n")
} else {
console.log(`sqlite-migration:${percent}`)
process.stderr.write(`sqlite-migration:${percent}${EOL}`)
}
},
})
} finally {
if (tty) process.stdout.write("\x1b[?25h")
if (tty) process.stderr.write("\x1b[?25h")
else {
console.log(`sqlite-migration:done`)
process.stderr.write(`sqlite-migration:done${EOL}`)
}
}
console.log("Database migration complete.")
process.stderr.write("Database migration complete." + EOL)
}
})
.usage("\n" + UI.logo())
@@ -138,6 +139,7 @@ const cli = yargs(hideBin(process.argv))
.command(GithubCommand)
.command(PrCommand)
.command(SessionCommand)
.command(DbCommand)
.fail((msg, err) => {
if (
msg?.startsWith("Unknown argument") ||
@@ -188,7 +190,7 @@ try {
if (formatted) UI.error(formatted)
if (formatted === undefined) {
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
console.error(e instanceof Error ? e.message : String(e))
process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL)
}
process.exitCode = 1
} finally {

View File

@@ -25,6 +25,7 @@ export const NotFoundError = NamedError.create(
const log = Log.create({ service: "db" })
export namespace Database {
export const Path = path.join(Global.Path.data, "opencode.db")
type Schema = typeof schema
export type Transaction = SQLiteTransaction<"sync", void, Schema>

View File

@@ -152,6 +152,7 @@ export namespace JsonMigration {
sqlite.exec("BEGIN TRANSACTION")
// Migrate projects first (no FK deps)
// Derive all IDs from file paths, not JSON content
const projectIds = new Set<string>()
const projectValues = [] as any[]
for (let i = 0; i < projectFiles.length; i += batchSize) {
@@ -161,13 +162,10 @@ export namespace JsonMigration {
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
if (!data?.id) {
errs.push(`project missing id: ${projectFiles[i + j]}`)
continue
}
projectIds.add(data.id)
const id = path.basename(projectFiles[i + j], ".json")
projectIds.add(id)
projectValues.push({
id: data.id,
id,
worktree: data.worktree ?? "/",
vcs: data.vcs,
name: data.name ?? undefined,
@@ -186,6 +184,9 @@ export namespace JsonMigration {
log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) })
// Migrate sessions (depends on projects)
// Derive all IDs from directory/file paths, not JSON content, since earlier
// migrations may have moved sessions to new directories without updating the JSON
const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file)))
const sessionIds = new Set<string>()
const sessionValues = [] as any[]
for (let i = 0; i < sessionFiles.length; i += batchSize) {
@@ -195,18 +196,16 @@ export namespace JsonMigration {
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
if (!data?.id || !data?.projectID) {
errs.push(`session missing id or projectID: ${sessionFiles[i + j]}`)
continue
}
if (!projectIds.has(data.projectID)) {
const id = path.basename(sessionFiles[i + j], ".json")
const projectID = sessionProjects[i + j]
if (!projectIds.has(projectID)) {
orphans.sessions++
continue
}
sessionIds.add(data.id)
sessionIds.add(id)
sessionValues.push({
id: data.id,
project_id: data.projectID,
id,
project_id: projectID,
parent_id: data.parentID ?? null,
slug: data.slug ?? "",
directory: data.directory ?? "",
@@ -253,11 +252,7 @@ export namespace JsonMigration {
const data = batch[j]
if (!data) continue
const file = allMessageFiles[i + j]
const id = data.id ?? path.basename(file, ".json")
if (!id) {
errs.push(`message missing id: ${file}`)
continue
}
const id = path.basename(file, ".json")
const sessionID = allMessageSessions[i + j]
messageSessions.set(id, sessionID)
const rest = data
@@ -287,12 +282,8 @@ export namespace JsonMigration {
const data = batch[j]
if (!data) continue
const file = partFiles[i + j]
const id = data.id ?? path.basename(file, ".json")
const messageID = data.messageID ?? path.basename(path.dirname(file))
if (!id || !messageID) {
errs.push(`part missing id/messageID/sessionID: ${file}`)
continue
}
const id = path.basename(file, ".json")
const messageID = path.basename(path.dirname(file))
const sessionID = messageSessions.get(messageID)
if (!sessionID) {
errs.push(`part missing message session: ${file}`)

View File

@@ -128,6 +128,28 @@ describe("JSON to SQLite migration", () => {
expect(projects[0].sandboxes).toEqual(["/test/sandbox"])
})
test("uses filename for project id when JSON has different value", async () => {
await Bun.write(
path.join(storageDir, "project", "proj_filename.json"),
JSON.stringify({
id: "proj_different_in_json", // Stale! Should be ignored
worktree: "/test/path",
vcs: "git",
name: "Test Project",
sandboxes: [],
}),
)
const stats = await JsonMigration.run(sqlite)
expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe("proj_filename") // Uses filename, not JSON id
})
test("migrates project with commands", async () => {
await writeProject(storageDir, {
id: "proj_with_commands",
@@ -285,6 +307,74 @@ describe("JSON to SQLite migration", () => {
expect(parts[0].data).not.toHaveProperty("sessionID")
})
test("uses filename for message id when JSON has different value", async () => {
await writeProject(storageDir, {
id: "proj_test123abc",
worktree: "/",
time: { created: Date.now(), updated: Date.now() },
sandboxes: [],
})
await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
await Bun.write(
path.join(storageDir, "message", "ses_test456def", "msg_from_filename.json"),
JSON.stringify({
id: "msg_different_in_json", // Stale! Should be ignored
sessionID: "ses_test456def",
role: "user",
agent: "default",
time: { created: 1700000000000 },
}),
)
const stats = await JsonMigration.run(sqlite)
expect(stats?.messages).toBe(1)
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe("msg_from_filename") // Uses filename, not JSON id
expect(messages[0].session_id).toBe("ses_test456def")
})
test("uses paths for part id and messageID when JSON has different values", async () => {
await writeProject(storageDir, {
id: "proj_test123abc",
worktree: "/",
time: { created: Date.now(), updated: Date.now() },
sandboxes: [],
})
await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
await Bun.write(
path.join(storageDir, "message", "ses_test456def", "msg_realmsgid.json"),
JSON.stringify({
role: "user",
agent: "default",
time: { created: 1700000000000 },
}),
)
await Bun.write(
path.join(storageDir, "part", "msg_realmsgid", "prt_from_filename.json"),
JSON.stringify({
id: "prt_different_in_json", // Stale! Should be ignored
messageID: "msg_different_in_json", // Stale! Should be ignored
sessionID: "ses_test456def",
type: "text",
text: "Hello",
}),
)
const stats = await JsonMigration.run(sqlite)
expect(stats?.parts).toBe(1)
const db = drizzle({ client: sqlite })
const parts = db.select().from(PartTable).all()
expect(parts.length).toBe(1)
expect(parts[0].id).toBe("prt_from_filename") // Uses filename, not JSON id
expect(parts[0].message_id).toBe("msg_realmsgid") // Uses parent dir, not JSON messageID
})
test("skips orphaned sessions (no parent project)", async () => {
await Bun.write(
path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"),
@@ -304,6 +394,72 @@ describe("JSON to SQLite migration", () => {
expect(stats?.sessions).toBe(0)
})
test("uses directory path for projectID when JSON has stale value", async () => {
// Simulates the scenario where earlier migration moved sessions to new
// git-based project directories but didn't update the projectID field
const gitBasedProjectID = "abc123gitcommit"
await writeProject(storageDir, {
id: gitBasedProjectID,
worktree: "/test/path",
vcs: "git",
time: { created: Date.now(), updated: Date.now() },
sandboxes: [],
})
// Session is in the git-based directory but JSON still has old projectID
await writeSession(storageDir, gitBasedProjectID, {
id: "ses_migrated",
projectID: "old-project-name", // Stale! Should be ignored
slug: "migrated-session",
directory: "/test/path",
title: "Migrated Session",
version: "1.0.0",
time: { created: 1700000000000, updated: 1700000001000 },
})
const stats = await JsonMigration.run(sqlite)
expect(stats?.sessions).toBe(1)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe("ses_migrated")
expect(sessions[0].project_id).toBe(gitBasedProjectID) // Uses directory, not stale JSON
})
test("uses filename for session id when JSON has different value", async () => {
await writeProject(storageDir, {
id: "proj_test123abc",
worktree: "/test/path",
time: { created: Date.now(), updated: Date.now() },
sandboxes: [],
})
await Bun.write(
path.join(storageDir, "session", "proj_test123abc", "ses_from_filename.json"),
JSON.stringify({
id: "ses_different_in_json", // Stale! Should be ignored
projectID: "proj_test123abc",
slug: "test-session",
directory: "/test/path",
title: "Test Session",
version: "1.0.0",
time: { created: 1700000000000, updated: 1700000001000 },
}),
)
const stats = await JsonMigration.run(sqlite)
expect(stats?.sessions).toBe(1)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe("ses_from_filename") // Uses filename, not JSON id
expect(sessions[0].project_id).toBe("proj_test123abc")
})
test("is idempotent (running twice doesn't duplicate)", async () => {
await writeProject(storageDir, {
id: "proj_test123abc",
@@ -666,8 +822,11 @@ describe("JSON to SQLite migration", () => {
const stats = await JsonMigration.run(sqlite)
expect(stats.projects).toBe(1)
expect(stats.sessions).toBe(1)
// Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename)
// Sessions: ses_test456def (valid), ses_missing_project (now uses dir path),
// ses_orphan (now uses dir path, ignores stale projectID)
expect(stats.projects).toBe(2)
expect(stats.sessions).toBe(3)
expect(stats.messages).toBe(1)
expect(stats.parts).toBe(1)
expect(stats.todos).toBe(1)
@@ -676,8 +835,8 @@ describe("JSON to SQLite migration", () => {
expect(stats.errors.length).toBeGreaterThanOrEqual(6)
const db = drizzle({ client: sqlite })
expect(db.select().from(ProjectTable).all().length).toBe(1)
expect(db.select().from(SessionTable).all().length).toBe(1)
expect(db.select().from(ProjectTable).all().length).toBe(2)
expect(db.select().from(SessionTable).all().length).toBe(3)
expect(db.select().from(MessageTable).all().length).toBe(1)
expect(db.select().from(PartTable).all().length).toBe(1)
expect(db.select().from(TodoTable).all().length).toBe(1)

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.2.2",
"version": "1.2.4",
"type": "module",
"license": "MIT",
"exports": {

View File

@@ -26,6 +26,8 @@ import ubuntuMono from "../assets/fonts/ubuntu-mono-nerd-font.woff2"
import ubuntuMonoBold from "../assets/fonts/ubuntu-mono-nerd-font-bold.woff2"
import iosevka from "../assets/fonts/iosevka-nerd-font.woff2"
import iosevkaBold from "../assets/fonts/iosevka-nerd-font-bold.woff2"
import geistMono from "../assets/fonts/GeistMonoNerdFontMono-Regular.woff2"
import geistMonoBold from "../assets/fonts/GeistMonoNerdFontMono-Bold.woff2"
type MonoFont = {
family: string
@@ -89,6 +91,11 @@ export const MONO_NERD_FONTS = [
regular: iosevka,
bold: iosevkaBold,
},
{
family: "GeistMono Nerd Font",
regular: geistMono,
bold: geistMonoBold,
},
] satisfies MonoFont[]
const monoNerdCss = MONO_NERD_FONTS.map(

View File

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

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.2.2",
"version": "1.2.4",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.2.2",
"version": "1.2.4",
"publisher": "sst-dev",
"repository": {
"type": "git",