Compare commits

..
Author SHA1 Message Date
Brendan Allan 8080b51a7f bundle vc redistributables on windows 2026-02-17 15:55:26 +08:00
Aiden ClineandGitHub ed4e4843c2 ci: update triage workflow (#13944) 2026-02-17 01:05:56 -06:00
Frank a93a1b93e1 wip: zen 2026-02-17 01:32:57 -05:00
Frank ace63b3ddb zen: glm 5 free 2026-02-17 01:12:13 -05:00
Brendan AllanandGitHub d338bd528c Hide server CLI on windows (#13936) 2026-02-17 12:43:25 +08:00
Goni ZahavyandGitHub ea2d089db0 ci: fixed missing if condition (#13934) 2026-02-17 12:42:55 +08:00
Goni ZahavyandGitHub 4226097228 ci: fixed Rust cache for 'cargo install' in publish.yml (#13907) 2026-02-17 12:13:33 +08:00
Aiden ClineandGitHub e35a4131d0 core: keep message part order stable when files resolve asynchronously (#13915) 2026-02-16 18:45:11 -06:00
Goni ZahavyandGitHub 0e669b6016 ci: use useblacksmith/stickydisk on linux runners only (#13909) 2026-02-16 18:27:04 -06:00
Goni ZahavyandGitHub 9163611989 ci: fixed apt cache not working in publish.yml (#13897) 2026-02-16 17:31:38 -06:00
James LongandGitHub d93cefd47a fix(website): fix site in safari 18 (#13894) 2026-02-16 17:39:28 -05:00
Aiden ClineandGitHub a580fb47d2 tweak: drop ids from attachments in tools, assign them in prompt.ts instead (#13890) 2026-02-16 14:59:57 -06:00
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 ZhengandGitHub 86e545a23e fix(opencode): ACP sessions never get LLM-generated titles (#13095) 2026-02-16 14:16:17 -06:00
Ariane EmoryandGitHub 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 SchadekandGitHub 160ba295a8 feat(opencode): add dfmt formatter support for D language files (#13867) 2026-02-16 13:14:35 -06:00
OpeOginniandGitHub 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
DaxandGitHub fdad823edc feat(cli): add db migrate command for JSON to SQLite migration (#13874) 2026-02-16 19:05:21 +00:00
Ryan VogelandGitHub 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 GlafirovandGitHub ef979ccfa8 fix: bump GitLab provider and auth plugin for mid-session token refresh (#13850) 2026-02-16 10:01:17 -06:00
Imanol MaizteguiandGitHub 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 KhardalianandGitHub 45fa5e7199 fix(core): remove unnecessary per-message title LLM calls (#13804) 2026-02-16 06:04:20 -06:00
ChujiangandGitHub 3ebf27aab9 fix(docs): correct critical translation errors in Russian zen page (#13830) 2026-02-16 06:02:48 -06:00
Aiden ClineandGitHub 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
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
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
dpuyosaandGitHub afd0716cbd feat(opencode): Add Venice support in temperature, topP, topK and smallOption (#13553) 2026-02-15 22:24:24 -06:00
Brendan AllanandGitHub 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 KaixinGitHubCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
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 ElbiligandGitHub 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 YaroshukandGitHub 3a3aa300bb feat(app): localize "free usage exceeded" error & "Add credits" clickable link (#13652) 2026-02-15 10:40:09 -06:00
Shane BishopandGitHub cf50a289db fix(desktop): issue viewing new files opened from the file tree (#13689) 2026-02-15 09:48:40 -06:00
Shoubhit DashandGitHub 3c85cf4fac fix(app): only navigate prompt history at input boundaries (#13690) 2026-02-15 07:47:19 -06:00
FilipandGitHub 878ddc6a0a fix(app): keybind [shift+tab] (#13695) 2026-02-15 07:46:56 -06:00
DenysandGitHub 3761121728 docs: add Ukrainian README translation (#13697) 2026-02-15 07:46:19 -06:00
zerone0xandGitHub 3aaa34be1e fix(desktop): focus window after update/relaunch (#13701) 2026-02-15 07:45:34 -06:00
Brandon Julio ThenaroandGitHub 985c2a3d15 feat: Add GeistMono Nerd Font to available mono font options (#13720) 2026-02-15 07:44:21 -06:00
Aiden ClineandGitHub eb553f53ac fix: ensure sqlite migration logs to stderr instead of stdout (#13691) 2026-02-15 00:41:16 -06:00
160 changed files with 2231 additions and 902 deletions
+1
View File
@@ -4,6 +4,7 @@ runs:
using: "composite"
steps:
- name: Mount Bun Cache
if: ${{ runner.os == 'Linux' }}
uses: useblacksmith/stickydisk@v1
with:
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
+18 -6
View File
@@ -137,7 +137,7 @@ jobs:
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
path: ~/apt-cache
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
restore-keys: |
${{ runner.os }}-${{ matrix.settings.target }}-apt-
@@ -145,8 +145,10 @@ jobs:
- name: install dependencies (ubuntu only)
if: contains(matrix.settings.host, 'ubuntu')
run: |
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo chmod -R a+rw ~/apt-cache
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -169,13 +171,23 @@ jobs:
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Resolve tauri portable SHA
if: contains(matrix.settings.host, 'ubuntu')
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- name: Install tauri-cli from portable appimage branch
uses: taiki-e/cache-cargo-install-action@v3
if: contains(matrix.settings.host, 'ubuntu')
run: |
cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
echo "Installed tauri-cli version:"
cargo tauri --version
with:
tool: tauri-cli
git: https://github.com/tauri-apps/tauri
# branch: feat/truly-portable-appimage
rev: ${{ env.TAURI_PORTABLE_SHA }}
- name: Show tauri-cli version
if: contains(matrix.settings.host, 'ubuntu')
run: cargo tauri --version
- name: Build and upload artifacts
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
+2
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
+62 -8
View File
@@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
model: opencode/minimax-m2.5
color: "#44BA81"
tools:
"*": false
@@ -12,6 +12,8 @@ You are a triage agent responsible for triaging github issues.
Use your github-triage tool to triage issues.
This file is the source of truth for ownership/routing rules.
## Labels
### windows
@@ -43,12 +45,30 @@ Desktop app issues:
**Only** add if the issue explicitly mentions nix.
If the issue does not mention nix, do not add nix.
If the issue mentions nix, assign to `rekram1-node`.
#### zen
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
#### core
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
Examples:
- LSP server behavior
- Harness behavior (agent + tools)
- Feature requests for server behavior
- Agent context construction
- API endpoints
- Provider integration issues
- New, broken, or poor-quality models
#### docs
Add if the issue requests better documentation or docs updates.
@@ -66,13 +86,47 @@ TUI issues potentially caused by our underlying TUI library:
When assigning to people here are the following rules:
adamdotdev:
ONLY assign adam if the issue will have the "desktop" label.
Desktop / Web:
Use for desktop-labeled issues only.
fwang:
ONLY assign fwang if the issue will have the "zen" label.
- adamdotdevin
- iamdavidhill
- Brendonovich
- nexxeln
jayair:
ONLY assign jayair if the issue will have the "docs" label.
Zen:
ONLY assign if the issue will have the "zen" label.
In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node.
- fwang
- MrMushrooooom
TUI (`packages/opencode/src/cli/cmd/tui/...`):
- thdxr for TUI UX/UI product decisions and interaction flow
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
Core (`packages/opencode/...`, excluding TUI subtree):
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
- rekram1-node for harness issues, provider issues, and other bug-squashing
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
Docs:
- R44VC0RP
Windows:
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
Determinism rules:
- If title + body does not contain "zen", do not add the "zen" label
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
- If title + body mentions nix/nixos, assign to `rekram1-node`
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
-3
View File
@@ -1,8 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
"provider": {
"opencode": {
"options": {},
+59 -26
View File
@@ -1,8 +1,22 @@
/// <reference path="../env.d.ts" />
// import { Octokit } from "@octokit/rest"
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
function pick<T>(items: readonly T[]) {
return items[Math.floor(Math.random() * items.length)]!
}
function getIssueNumber(): number {
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
if (!issue) throw new Error("ISSUE_NUMBER env var not set")
@@ -29,60 +43,79 @@ export default tool({
description: DESCRIPTION,
args: {
assignee: tool.schema
.enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"])
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "desktop", "zen", "docs", "windows"]))
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
.default([]),
},
async execute(args) {
const issue = getIssueNumber()
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
const owner = "anomalyco"
const repo = "opencode"
const results: string[] = []
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
const web = labels.includes("web")
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
const nix = /\bnix(os)?\b/.test(text)
if (args.assignee === "adamdotdevin" && !args.labels.includes("desktop")) {
throw new Error("Only desktop issues should be assigned to adamdotdevin")
if (labels.includes("nix") && !nix) {
labels = labels.filter((x) => x !== "nix")
results.push("Dropped label: nix (issue does not mention nix)")
}
if (args.assignee === "fwang" && !args.labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang")
const assignee = nix
? "rekram1-node"
: web
? pick(TEAM.desktop)
: args.assignee === "jlongster"
? "thdxr"
: args.assignee
if (args.assignee === "jlongster" && assignee === "thdxr") {
results.push("Remapped assignee: jlongster -> thdxr (jlongster not assignable yet)")
}
if (args.assignee === "kommander" && !args.labels.includes("opentui")) {
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")
}
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
}
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
}
if (assignee === "Hona" && !labels.includes("windows")) {
throw new Error("Only windows issues should be assigned to Hona")
}
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
throw new Error("Only docs issues should be assigned to R44VC0RP")
}
if (assignee === "kommander" && !labels.includes("opentui")) {
throw new Error("Only opentui issues should be assigned to kommander")
}
// await octokit.rest.issues.addAssignees({
// owner,
// repo,
// issue_number: issue,
// assignees: [args.assignee],
// })
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
method: "POST",
body: JSON.stringify({ assignees: [args.assignee] }),
body: JSON.stringify({ assignees: [assignee] }),
})
results.push(`Assigned @${args.assignee} to issue #${issue}`)
const labels: string[] = args.labels.map((label) => (label === "desktop" ? "web" : label))
results.push(`Assigned @${assignee} to issue #${issue}`)
if (labels.length > 0) {
// await octokit.rest.issues.addLabels({
// owner,
// repo,
// issue_number: issue,
// labels,
// })
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
method: "POST",
body: JSON.stringify({ labels }),
})
results.push(`Added labels: ${args.labels.join(", ")}`)
results.push(`Added labels: ${labels.join(", ")}`)
}
return results.join("\n")
+3 -85
View File
@@ -1,88 +1,6 @@
Use this tool to assign and/or label a GitHub issue.
You can assign the following users:
- thdxr
- adamdotdevin
- fwang
- jayair
- kommander
- rekram1-node
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
You can use the following labels:
- nix
- opentui
- perf
- web
- zen
- docs
Always try to assign an issue, if in doubt, assign rekram1-node to it.
## Breakdown of responsibilities:
### thdxr
Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him.
This relates to OpenCode server primarily but has overlap with just about anything
### adamdotdevin
Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him.
### fwang
Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue.
### jayair
Jay is responsible for documentation. If there is an issue relating to documentation assign him.
### kommander
Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about:
- random characters on screen
- keybinds not working on different terminals
- general terminal stuff
Then assign the issue to Him.
### rekram1-node
ALL BUGS SHOULD BE assigned to rekram1-node unless they have the `opentui` label.
Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things.
If no one else makes sense to assign, assign rekram1-node to it.
Always assign to aiden if the issue mentions "acp", "zed", or model performance issues
## Breakdown of Labels:
### nix
Any issue that mentions nix, or nixos should have a nix label
### opentui
Anything relating to the TUI itself should have an opentui label
### perf
Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label
### desktop
Anything related to `opencode web` command or the desktop app should have a desktop label. Never add this label for anything terminal/tui related
### zen
Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label
### docs
Anything related to the documentation should have a docs label
### windows
Use for any issue that involves the windows OS
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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)
+2 -1
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
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)
+2 -1
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)
+2 -1
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)
+19 -19
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=="],
+10
View File
@@ -145,6 +145,16 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS18"),
new sst.Secret("ZEN_MODELS19"),
new sst.Secret("ZEN_MODELS20"),
new sst.Secret("ZEN_MODELS21"),
new sst.Secret("ZEN_MODELS22"),
new sst.Secret("ZEN_MODELS23"),
new sst.Secret("ZEN_MODELS24"),
new sst.Secret("ZEN_MODELS25"),
new sst.Secret("ZEN_MODELS26"),
new sst.Secret("ZEN_MODELS27"),
new sst.Secret("ZEN_MODELS28"),
new sst.Secret("ZEN_MODELS29"),
new sst.Secret("ZEN_MODELS30"),
]
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
+4 -4
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="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.4",
"version": "1.2.6",
"description": "",
"type": "module",
"exports": {
+2 -20
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
@@ -73,7 +73,7 @@ describe("prompt-input history", () => {
expect(original[1].selection?.startLine).toBe(1)
})
test("canNavigateHistoryAtCursor only allows multiline boundaries", () => {
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
@@ -85,7 +85,16 @@ describe("prompt-input history", () => {
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1, true)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1, true)).toBe(false)
})
})
@@ -4,11 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) {
if (!text.includes("\n")) return true
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
const position = Math.max(0, Math.min(cursor, text.length))
if (direction === "up") return !text.slice(0, position).includes("\n")
return !text.slice(position).includes("\n")
const atStart = position === 0
const atEnd = position === text.length
if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0
return position === text.length
}
export function clonePromptParts(prompt: Prompt): Prompt {
@@ -128,6 +128,7 @@ export const SettingsGeneral: Component = () => {
{ value: "roboto-mono", label: "font.option.robotoMono" },
{ value: "source-code-pro", label: "font.option.sourceCodePro" },
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" },
{ value: "geist-mono", label: "font.option.geistMono" },
] as const
const fontOptionsList = [...fontOptions]
+3 -1
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()
+1
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) {
+1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1 -28
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,
})
+1 -15
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)
@@ -1,4 +1,3 @@
import type { CommandOption } from "@/context/command"
import { batch } from "solid-js"
export const focusTerminalById = (id: string) => {
@@ -36,10 +35,6 @@ export const createOpenReviewFile = (input: {
}
}
export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => {
return sections.flatMap((section) => section)
}
export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => {
const fromIndex = tabs.indexOf(from)
const toIndex = tabs.indexOf(to)
@@ -19,35 +19,14 @@ import { showToast } from "@opencode-ai/ui/toast"
import { findLast } from "@opencode-ai/util/array"
import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { combineCommandSections } from "@/pages/session/helpers"
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
export type SessionCommandContext = {
command: ReturnType<typeof useCommand>
dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile>
language: ReturnType<typeof useLanguage>
local: ReturnType<typeof useLocal>
permission: ReturnType<typeof usePermission>
prompt: ReturnType<typeof usePrompt>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
terminal: ReturnType<typeof useTerminal>
layout: ReturnType<typeof useLayout>
params: ReturnType<typeof useParams>
navigate: ReturnType<typeof useNavigate>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
status: () => { type: string }
userMessages: () => UserMessage[]
visibleUserMessages: () => UserMessage[]
activeMessage: () => UserMessage | undefined
showAllFiles: () => void
navigateMessageByOffset: (offset: number) => void
setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
setActiveMessage: (message: UserMessage | undefined) => void
addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => void
}
@@ -58,45 +37,88 @@ const withCategory = (category: string) => {
})
}
export const useSessionCommands = (input: SessionCommandContext) => {
const sessionCommand = withCategory(input.language.t("command.category.session"))
const fileCommand = withCategory(input.language.t("command.category.file"))
const contextCommand = withCategory(input.language.t("command.category.context"))
const viewCommand = withCategory(input.language.t("command.category.view"))
const terminalCommand = withCategory(input.language.t("command.category.terminal"))
const modelCommand = withCategory(input.language.t("command.category.model"))
const mcpCommand = withCategory(input.language.t("command.category.mcp"))
const agentCommand = withCategory(input.language.t("command.category.agent"))
const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
export const useSessionCommands = (args: SessionCommandContext) => {
const command = useCommand()
const dialog = useDialog()
const file = useFile()
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
const sdk = useSDK()
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const idle = { type: "idle" as const }
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
const visibleUserMessages = createMemo(() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
})
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = content.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const addSelectionToContext = (path: string, selection: FileSelection) => {
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const sessionCommand = withCategory(language.t("command.category.session"))
const fileCommand = withCategory(language.t("command.category.file"))
const contextCommand = withCategory(language.t("command.category.context"))
const viewCommand = withCategory(language.t("command.category.view"))
const terminalCommand = withCategory(language.t("command.category.terminal"))
const modelCommand = withCategory(language.t("command.category.model"))
const mcpCommand = withCategory(language.t("command.category.mcp"))
const agentCommand = withCategory(language.t("command.category.agent"))
const permissionsCommand = withCategory(language.t("command.category.permissions"))
const sessionCommands = createMemo(() => [
sessionCommand({
id: "session.new",
title: input.language.t("command.session.new"),
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => input.navigate(`/${input.params.dir}/session`),
onSelect: () => navigate(`/${params.dir}/session`),
}),
])
const fileCommands = createMemo(() => [
fileCommand({
id: "file.open",
title: input.language.t("command.file.open"),
description: input.language.t("palette.search.placeholder"),
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+p",
slash: "open",
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={args.showAllFiles} />),
}),
fileCommand({
id: "tab.close",
title: input.language.t("command.tab.close"),
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !input.tabs().active(),
disabled: !tabs().active(),
onSelect: () => {
const active = input.tabs().active()
const active = tabs().active()
if (!active) return
input.tabs().close(active)
tabs().close(active)
},
}),
])
@@ -104,30 +126,30 @@ export const useSessionCommands = (input: SessionCommandContext) => {
const contextCommands = createMemo(() => [
contextCommand({
id: "context.addSelection",
title: input.language.t("command.context.addSelection"),
description: input.language.t("command.context.addSelection.description"),
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext({
active: input.tabs().active(),
pathFromTab: input.file.pathFromTab,
selectedLines: input.file.selectedLines,
active: tabs().active(),
pathFromTab: file.pathFromTab,
selectedLines: file.selectedLines,
}),
onSelect: () => {
const active = input.tabs().active()
const active = tabs().active()
if (!active) return
const path = input.file.pathFromTab(active)
const path = file.pathFromTab(active)
if (!path) return
const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: input.language.t("toast.context.noLineSelection.title"),
description: input.language.t("toast.context.noLineSelection.description"),
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
})
return
}
input.addSelectionToContext(path, selectionFromLines(range))
addSelectionToContext(path, selectionFromLines(range))
},
}),
])
@@ -135,50 +157,50 @@ export const useSessionCommands = (input: SessionCommandContext) => {
const viewCommands = createMemo(() => [
viewCommand({
id: "terminal.toggle",
title: input.language.t("command.terminal.toggle"),
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => input.view().terminal.toggle(),
onSelect: () => view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: input.language.t("command.review.toggle"),
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => input.view().reviewPanel.toggle(),
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: input.language.t("command.fileTree.toggle"),
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => input.layout.fileTree.toggle(),
onSelect: () => layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: input.language.t("command.input.focus"),
title: language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: () => input.focusInput(),
onSelect: () => args.focusInput(),
}),
terminalCommand({
id: "terminal.new",
title: input.language.t("command.terminal.new"),
description: input.language.t("command.terminal.new.description"),
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (input.terminal.all().length > 0) input.terminal.new()
input.view().terminal.open()
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
},
}),
viewCommand({
id: "steps.toggle",
title: input.language.t("command.steps.toggle"),
description: input.language.t("command.steps.toggle.description"),
title: language.t("command.steps.toggle"),
description: language.t("command.steps.toggle.description"),
keybind: "mod+e",
slash: "steps",
disabled: !input.params.id,
disabled: !params.id,
onSelect: () => {
const msg = input.activeMessage()
const msg = args.activeMessage()
if (!msg) return
input.setExpanded(msg.id, (open: boolean | undefined) => !open)
args.setExpanded(msg.id, (open: boolean | undefined) => !open)
},
}),
])
@@ -186,61 +208,61 @@ export const useSessionCommands = (input: SessionCommandContext) => {
const messageCommands = createMemo(() => [
sessionCommand({
id: "message.previous",
title: input.language.t("command.message.previous"),
description: input.language.t("command.message.previous.description"),
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+arrowup",
disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(-1),
disabled: !params.id,
onSelect: () => args.navigateMessageByOffset(-1),
}),
sessionCommand({
id: "message.next",
title: input.language.t("command.message.next"),
description: input.language.t("command.message.next.description"),
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+arrowdown",
disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(1),
disabled: !params.id,
onSelect: () => args.navigateMessageByOffset(1),
}),
])
const agentCommands = createMemo(() => [
modelCommand({
id: "model.choose",
title: input.language.t("command.model.choose"),
description: input.language.t("command.model.choose.description"),
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => input.dialog.show(() => <DialogSelectModel />),
onSelect: () => dialog.show(() => <DialogSelectModel />),
}),
mcpCommand({
id: "mcp.toggle",
title: input.language.t("command.mcp.toggle"),
description: input.language.t("command.mcp.toggle.description"),
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
onSelect: () => dialog.show(() => <DialogSelectMcp />),
}),
agentCommand({
id: "agent.cycle",
title: input.language.t("command.agent.cycle"),
description: input.language.t("command.agent.cycle.description"),
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => input.local.agent.move(1),
onSelect: () => local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: input.language.t("command.agent.cycle.reverse"),
description: input.language.t("command.agent.cycle.reverse.description"),
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => input.local.agent.move(-1),
onSelect: () => local.agent.move(-1),
}),
modelCommand({
id: "model.variant.cycle",
title: input.language.t("command.model.variant.cycle"),
description: input.language.t("command.model.variant.cycle.description"),
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => {
input.local.model.variant.cycle()
local.model.variant.cycle()
},
}),
])
@@ -249,22 +271,22 @@ export const useSessionCommands = (input: SessionCommandContext) => {
permissionsCommand({
id: "permissions.autoaccept",
title:
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
? input.language.t("command.permissions.autoaccept.disable")
: input.language.t("command.permissions.autoaccept.enable"),
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: !input.params.id || !input.permission.permissionsEnabled(),
disabled: !params.id || !permission.permissionsEnabled(),
onSelect: () => {
const sessionID = input.params.id
const sessionID = params.id
if (!sessionID) return
input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({
title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
? input.language.t("toast.permissions.autoaccept.on.title")
: input.language.t("toast.permissions.autoaccept.off.title"),
description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
? input.language.t("toast.permissions.autoaccept.on.description")
: input.language.t("toast.permissions.autoaccept.off.description"),
title: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
},
}),
@@ -273,71 +295,71 @@ export const useSessionCommands = (input: SessionCommandContext) => {
const sessionActionCommands = createMemo(() => [
sessionCommand({
id: "session.undo",
title: input.language.t("command.session.undo"),
description: input.language.t("command.session.undo.description"),
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !input.params.id || input.visibleUserMessages().length === 0,
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = input.params.id
const sessionID = params.id
if (!sessionID) return
if (input.status()?.type !== "idle") {
await input.sdk.client.session.abort({ sessionID }).catch(() => {})
if (status()?.type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = input.info()?.revert?.messageID
const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await input.sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = input.sync.data.part[message.id]
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
input.prompt.set(restored)
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
input.setActiveMessage(priorMessage)
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
args.setActiveMessage(priorMessage)
},
}),
sessionCommand({
id: "session.redo",
title: input.language.t("command.session.redo"),
description: input.language.t("command.session.redo.description"),
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !input.params.id || !input.info()?.revert?.messageID,
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const sessionID = input.params.id
const sessionID = params.id
if (!sessionID) return
const revertMessageID = input.info()?.revert?.messageID
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
await input.sdk.client.session.unrevert({ sessionID })
input.prompt.reset()
const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
input.setActiveMessage(lastMsg)
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
args.setActiveMessage(lastMsg)
return
}
await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
input.setActiveMessage(priorMsg)
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
args.setActiveMessage(priorMsg)
},
}),
sessionCommand({
id: "session.compact",
title: input.language.t("command.session.compact"),
description: input.language.t("command.session.compact.description"),
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !input.params.id || input.visibleUserMessages().length === 0,
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = input.params.id
const sessionID = params.id
if (!sessionID) return
const model = input.local.model.current()
const model = local.model.current()
if (!model) {
showToast({
title: input.language.t("toast.model.none.title"),
description: input.language.t("toast.model.none.description"),
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await input.sdk.client.session.summarize({
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
@@ -346,29 +368,27 @@ export const useSessionCommands = (input: SessionCommandContext) => {
}),
sessionCommand({
id: "session.fork",
title: input.language.t("command.session.fork"),
description: input.language.t("command.session.fork.description"),
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: () => input.dialog.show(() => <DialogFork />),
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
}),
])
const shareCommands = createMemo(() => {
if (input.sync.data.config.share === "disabled") return []
if (sync.data.config.share === "disabled") return []
return [
sessionCommand({
id: "session.share",
title: input.info()?.share?.url
? input.language.t("session.share.copy.copyLink")
: input.language.t("command.session.share"),
description: input.info()?.share?.url
? input.language.t("toast.session.share.success.description")
: input.language.t("command.session.share.description"),
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !input.params.id,
disabled: !params.id,
onSelect: async () => {
if (!input.params.id) return
if (!params.id) return
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
@@ -398,7 +418,7 @@ export const useSessionCommands = (input: SessionCommandContext) => {
const ok = await write(url)
if (!ok) {
showToast({
title: input.language.t("toast.session.share.copyFailed.title"),
title: language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
@@ -406,27 +426,27 @@ export const useSessionCommands = (input: SessionCommandContext) => {
showToast({
title: existing
? input.language.t("session.share.copy.copied")
: input.language.t("toast.session.share.success.title"),
description: input.language.t("toast.session.share.success.description"),
? language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
variant: "success",
})
}
const existing = input.info()?.share?.url
const existing = info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
const url = await input.sdk.client.session
.share({ sessionID: input.params.id })
const url = await sdk.client.session
.share({ sessionID: params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: input.language.t("toast.session.share.failed.title"),
description: input.language.t("toast.session.share.failed.description"),
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
variant: "error",
})
return
@@ -437,25 +457,25 @@ export const useSessionCommands = (input: SessionCommandContext) => {
}),
sessionCommand({
id: "session.unshare",
title: input.language.t("command.session.unshare"),
description: input.language.t("command.session.unshare.description"),
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !input.params.id || !input.info()?.share?.url,
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!input.params.id) return
await input.sdk.client.session
.unshare({ sessionID: input.params.id })
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: input.language.t("toast.session.unshare.success.title"),
description: input.language.t("toast.session.unshare.success.description"),
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: input.language.t("toast.session.unshare.failed.title"),
description: input.language.t("toast.session.unshare.failed.description"),
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
@@ -464,8 +484,8 @@ export const useSessionCommands = (input: SessionCommandContext) => {
]
})
input.command.register("session", () =>
combineCommandSections([
command.register("session", () =>
[
sessionCommands(),
fileCommands(),
contextCommands(),
@@ -475,6 +495,6 @@ export const useSessionCommands = (input: SessionCommandContext) => {
permissionCommands(),
sessionActionCommands(),
shareCommands(),
]),
].flatMap((section) => section),
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.4",
"version": "1.2.6",
"type": "module",
"license": "MIT",
"scripts": {
+15 -15
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;
}
}
+1 -1
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",
@@ -9,7 +9,7 @@ const stage = process.argv[2]
if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..")
const PARTS = 20
const PARTS = 30
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
@@ -7,7 +7,7 @@ import { ZenData } from "../src/model"
const root = path.resolve(process.cwd(), "..", "..", "..")
const models = await $`bun sst secret list`.cwd(root).text()
const PARTS = 20
const PARTS = 30
// read the line starting with "ZEN_MODELS"
const lines = models.split("\n")
+11 -1
View File
@@ -102,7 +102,17 @@ export namespace ZenData {
Resource.ZEN_MODELS17.value +
Resource.ZEN_MODELS18.value +
Resource.ZEN_MODELS19.value +
Resource.ZEN_MODELS20.value,
Resource.ZEN_MODELS20.value +
Resource.ZEN_MODELS21.value +
Resource.ZEN_MODELS22.value +
Resource.ZEN_MODELS23.value +
Resource.ZEN_MODELS24.value +
Resource.ZEN_MODELS25.value +
Resource.ZEN_MODELS26.value +
Resource.ZEN_MODELS27.value +
Resource.ZEN_MODELS28.value +
Resource.ZEN_MODELS29.value +
Resource.ZEN_MODELS30.value,
)
const { models, providers, providerFamilies } = ModelsSchema.parse(json)
return {
+40
View File
@@ -181,10 +181,50 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS21": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS22": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS23": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS24": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS25": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS26": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS27": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS28": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS29": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS30": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
+1 -1
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",
+40
View File
@@ -181,10 +181,50 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS21": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS22": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS23": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS24": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS25": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS26": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS27": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS28": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS29": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS30": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
+1 -1
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",
+40
View File
@@ -181,10 +181,50 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS21": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS22": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS23": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS24": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS25": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS26": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS27": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS28": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS29": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS30": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
+1 -1
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": {
+84 -7
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"
+2 -9
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" }
+142 -23
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,114 @@ 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());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
#[cfg(windows)]
cmd.creation_flags(0x0800_0000);
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 +460,6 @@ pub fn serve(
let _ = tx.send(payload);
}
}
_ => {}
}
future::ready(())
@@ -1,145 +0,0 @@
//! Windows Job Object for reliable child process cleanup.
//!
//! This module provides a wrapper around Windows Job Objects with the
//! `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` flag set. When the job object handle
//! is closed (including when the parent process exits or crashes), Windows
//! automatically terminates all processes assigned to the job.
//!
//! This is more reliable than manual cleanup because it works even if:
//! - The parent process crashes
//! - The parent is killed via Task Manager
//! - The RunEvent::Exit handler fails to run
use std::io::{Error, Result};
#[cfg(windows)]
use std::sync::Mutex;
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
/// A Windows Job Object configured to kill all assigned processes when closed.
///
/// When this struct is dropped or when the owning process exits (even abnormally),
/// Windows will automatically terminate all processes that have been assigned to it.
pub struct JobObject(HANDLE);
// SAFETY: HANDLE is just a pointer-sized value, and Windows job objects
// can be safely accessed from multiple threads.
unsafe impl Send for JobObject {}
unsafe impl Sync for JobObject {}
impl JobObject {
/// Creates a new anonymous job object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set.
///
/// When the last handle to this job is closed (including on process exit),
/// Windows will terminate all processes assigned to the job.
pub fn new() -> Result<Self> {
unsafe {
// Create an anonymous job object
let job = CreateJobObjectW(None, None).map_err(|e| Error::other(e.message()))?;
// Configure the job to kill all processes when the handle is closed
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&info as *const _ as *const std::ffi::c_void,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
.map_err(|e| Error::other(e.message()))?;
Ok(Self(job))
}
}
/// Assigns a process to this job object by its process ID.
///
/// Once assigned, the process will be terminated when this job object is dropped
/// or when the owning process exits.
///
/// # Arguments
/// * `pid` - The process ID of the process to assign
pub fn assign_pid(&self, pid: u32) -> Result<()> {
unsafe {
// Open a handle to the process with the minimum required permissions
// PROCESS_SET_QUOTA and PROCESS_TERMINATE are required by AssignProcessToJobObject
let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid)
.map_err(|e| Error::other(e.message()))?;
// Assign the process to the job
let result = AssignProcessToJobObject(self.0, process);
// Close our handle to the process - the job object maintains its own reference
let _ = CloseHandle(process);
result.map_err(|e| Error::other(e.message()))
}
}
}
impl Drop for JobObject {
fn drop(&mut self) {
unsafe {
// When this handle is closed and it's the last handle to the job,
// Windows will terminate all processes in the job due to KILL_ON_JOB_CLOSE
let _ = CloseHandle(self.0);
}
}
}
/// Holds the Windows Job Object that ensures child processes are killed when the app exits.
/// On Windows, when the job object handle is closed (including on crash), all assigned
/// processes are automatically terminated by the OS.
#[cfg(windows)]
pub struct JobObjectState {
job: Mutex<Option<JobObject>>,
error: Mutex<Option<String>>,
}
#[cfg(windows)]
impl JobObjectState {
pub fn new() -> Self {
match JobObject::new() {
Ok(job) => Self {
job: Mutex::new(Some(job)),
error: Mutex::new(None),
},
Err(e) => {
tracing::error!("Failed to create job object: {e}");
Self {
job: Mutex::new(None),
error: Mutex::new(Some(format!("Failed to create job object: {e}"))),
}
}
}
}
pub fn assign_pid(&self, pid: u32) {
if let Some(job) = self.job.lock().unwrap().as_ref() {
if let Err(e) = job.assign_pid(pid) {
tracing::error!(pid, "Failed to assign process to job object: {e}");
*self.error.lock().unwrap() =
Some(format!("Failed to assign process to job object: {e}"));
} else {
tracing::info!(pid, "Assigned process to job object for automatic cleanup");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_job_object_creation() {
let job = JobObject::new();
assert!(job.is_ok(), "Failed to create job object: {:?}", job.err());
}
}
+3 -14
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 });
}
@@ -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));
}
}
+2 -9
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")
+19 -35
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() {
+1 -1
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},
};
+28 -3
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
+3 -1
View File
@@ -34,6 +34,7 @@
"active": true,
"targets": ["deb", "rpm", "dmg", "nsis", "app"],
"externalBin": ["sidecars/opencode-cli"],
"resources": ["resources/vc_redist.x64.exe"],
"linux": {
"rpm": {
"compression": {
@@ -48,7 +49,8 @@
"nsis": {
"installerIcon": "icons/dev/icon.ico",
"headerImage": "assets/nsis-header.bmp",
"sidebarImage": "assets/nsis-sidebar.bmp"
"sidebarImage": "assets/nsis-sidebar.bmp",
"installerHooks": "./windows/hooks.nsh"
}
}
},
@@ -11,9 +11,11 @@
"icons/prod/icon.icns",
"icons/prod/icon.ico"
],
"resources": ["resources/vc_redist.x64.exe"],
"windows": {
"nsis": {
"installerIcon": "icons/prod/icon.ico"
"installerIcon": "icons/prod/icon.ico",
"installerHooks": "./windows/hooks.nsh"
}
},
"linux": {
@@ -0,0 +1,24 @@
!include "FileFunc.nsh"
!macro NSIS_HOOK_POSTINSTALL
ReadRegDWord $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
${If} $0 == 1
DetailPrint "Visual C++ Redistributable already installed"
Goto vcredist_done
${EndIf}
${If} ${FileExists} "$INSTDIR\resources\vc_redist.x64.exe"
DetailPrint "Installing Visual C++ Redistributable..."
CopyFiles "$INSTDIR\resources\vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe"
ExecWait '"$TEMP\vc_redist.x64.exe" /install /quiet /norestart' $0
${If} $0 == 0
DetailPrint "Visual C++ Redistributable installed successfully"
${Else}
MessageBox MB_ICONEXCLAMATION "Visual C++ installation failed. Some features may not work."
${EndIf}
Delete "$TEMP\vc_redist.x64.exe"
Delete "$INSTDIR\resources\vc_redist.x64.exe"
${EndIf}
vcredist_done:
!macroend
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.4",
"version": "1.2.6",
"private": true,
"type": "module",
"license": "MIT",
+40
View File
@@ -181,10 +181,50 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS21": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS22": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS23": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS24": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS25": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS26": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS27": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS28": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS29": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS30": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
+6 -6
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"]
+1 -1
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",
+40
View File
@@ -181,10 +181,50 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS21": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS22": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS23": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS24": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS25": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS26": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS27": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS28": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS29": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS30": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
+3 -3
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",
+10
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
-1
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 },
+51 -1
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: () => {},
})
+25 -1
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",
+21 -1
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,
})
@@ -247,7 +247,8 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
options.push(
...sortedFiles.map((item): AutocompleteOption => {
const fullPath = `${process.cwd()}/${item}`
const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = `${baseDir}/${item}`
const urlObj = pathToFileURL(fullPath)
let filename = item
if (lineRange && !item.endsWith("/")) {
@@ -2,7 +2,7 @@ import { createMemo, createSignal, For } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
const themeCount = Object.keys(DEFAULT_THEMES).length
const themeTip = `Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${themeCount} built-in themes`
const themeTip = `Use {highlight}/themes{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${themeCount} built-in themes`
type TipPart = { text: string; highlight: boolean }
@@ -126,7 +126,7 @@ const TIPS = [
"Use {highlight}{file:path}{/highlight} to include file contents in config values",
"Use {highlight}instructions{/highlight} in config to load additional rules files",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request",
"Configure {highlight}steps{/highlight} to limit agentic iterations per request",
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools',
'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server',
"Override global tool settings per agent configuration",
@@ -147,7 +147,6 @@ const TIPS = [
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog",
"Use {highlight}/details{/highlight} to toggle tool execution details visibility",
"Use {highlight}/rename{/highlight} to rename the current session",
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell",
]
+1
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")
+18
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
},
}
+10 -10
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 {
+52 -4
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"] = {
+9 -4
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 {}
}

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