diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..9a888adbb25 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# web + desktop packages +packages/app/ @adamdotdevin +packages/tauri/ @adamdotdevin +packages/desktop/ @adamdotdevin diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 4c2b63711a8..fe1ec8409b4 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -11,6 +11,14 @@ body: validations: required: true + - type: input + id: plugins + attributes: + label: Plugins + description: What plugins are you using? + validations: + required: false + - type: input id: opencode-version attributes: diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index dc82d297bd1..53aa2a725eb 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -28,8 +28,8 @@ jobs: OPENCODE_PERMISSION: | { "bash": { - "gh issue*": "allow", - "*": "deny" + "*": "deny", + "gh issue*": "allow" }, "webfetch": "deny" } diff --git a/.github/workflows/duplicate-prs.yml b/.github/workflows/duplicate-prs.yml new file mode 100644 index 00000000000..32606858958 --- /dev/null +++ b/.github/workflows/duplicate-prs.yml @@ -0,0 +1,65 @@ +name: Duplicate PR Check + +on: + pull_request_target: + types: [opened] + +jobs: + check-duplicates: + if: | + github.event.pull_request.user.login != 'actions-user' && + github.event.pull_request.user.login != 'opencode' && + github.event.pull_request.user.login != 'rekram1-node' && + github.event.pull_request.user.login != 'thdxr' && + github.event.pull_request.user.login != 'kommander' && + github.event.pull_request.user.login != 'jayair' && + github.event.pull_request.user.login != 'fwang' && + github.event.pull_request.user.login != 'adamdotdevin' && + github.event.pull_request.user.login != 'iamdavidhill' && + github.event.pull_request.user.login != 'opencode-agent[bot]' + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Install dependencies + run: bun install + + - name: Install opencode + run: curl -fsSL https://opencode.ai/install | bash + + - name: Build prompt + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + { + echo "Check for duplicate PRs related to this new PR:" + echo "" + echo "CURRENT_PR_NUMBER: $PR_NUMBER" + echo "" + echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)" + echo "" + echo "Description:" + gh pr view "$PR_NUMBER" --json body --jq .body + } > pr_info.txt + + - name: Check for duplicate PRs + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates") + + gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_ + + $COMMENT" diff --git a/.github/workflows/nix-desktop.yml b/.github/workflows/nix-desktop.yml new file mode 100644 index 00000000000..54ce55be069 --- /dev/null +++ b/.github/workflows/nix-desktop.yml @@ -0,0 +1,35 @@ +name: nix desktop + +on: + push: + branches: [dev] + paths: + - "flake.nix" + - "flake.lock" + - "nix/**" + - "packages/app/**" + - "packages/desktop/**" + workflow_dispatch: + +jobs: + build-desktop: + strategy: + fail-fast: false + matrix: + os: + - blacksmith-4vcpu-ubuntu-2404 + - macos-latest + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Nix + uses: DeterminateSystems/nix-installer-action@v21 + + - name: Build desktop via flake + run: | + set -euo pipefail + nix --version + nix build .#desktop -L diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ec98d7061b3..d6e6876947f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,7 +31,7 @@ permissions: jobs: publish: runs-on: blacksmith-4vcpu-ubuntu-2404 - if: github.repository == 'sst/opencode' + if: github.repository == 'anomalyco/opencode' steps: - uses: actions/checkout@v3 with: @@ -172,7 +172,8 @@ jobs: - name: Install tauri-cli from portable appimage branch if: contains(matrix.settings.host, 'ubuntu') run: | - cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force + # cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force + cargo install tauri-cli --git https://github.com/brendonovich/tauri --branch appimage-sidecar-binaries --force echo "Installed tauri-cli version:" cargo tauri --version diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 44bfeb33661..93b01bafa2b 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -47,7 +47,7 @@ jobs: env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "gh pr review*": "deny", "*": "deny" } }' + OPENCODE_PERMISSION: '{ "bash": { "*": "deny", "gh*": "allow", "gh pr review*": "deny" } }' PR_TITLE: ${{ steps.pr-details.outputs.title }} run: | PR_BODY=$(jq -r .body pr_data.json) diff --git a/.github/workflows/stats.yml b/.github/workflows/stats.yml index 57e93642b27..824733901d6 100644 --- a/.github/workflows/stats.yml +++ b/.github/workflows/stats.yml @@ -9,7 +9,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: stats: - if: github.repository == 'sst/opencode' + if: github.repository == 'anomalyco/opencode' runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write diff --git a/.opencode/agent/duplicate-pr.md b/.opencode/agent/duplicate-pr.md new file mode 100644 index 00000000000..c9c932ef790 --- /dev/null +++ b/.opencode/agent/duplicate-pr.md @@ -0,0 +1,26 @@ +--- +mode: primary +hidden: true +model: opencode/claude-haiku-4-5 +color: "#E67E22" +tools: + "*": false + "github-pr-search": true +--- + +You are a duplicate PR detection agent. When a PR is opened, your job is to search for potentially duplicate or related open PRs. + +Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature. + +IMPORTANT: The input will contain a line `CURRENT_PR_NUMBER: NNNN`. This is the current PR number, you should not mark that the current PR as a duplicate of itself. + +Search using keywords from the PR title and description. Try multiple searches with different relevant terms. + +If you find potential duplicates: + +- List them with their titles and URLs +- Briefly explain why they might be related + +If no duplicates are found, say so clearly. BUT ONLY SAY "No duplicate PRs found" (don't say anything else if no dups) + +Keep your response concise and actionable. diff --git a/.opencode/command/issues.md b/.opencode/command/issues.md index 20ac4c18024..75b59616743 100644 --- a/.opencode/command/issues.md +++ b/.opencode/command/issues.md @@ -3,7 +3,7 @@ description: "find issue(s) on github" model: opencode/claude-haiku-4-5 --- -Search through existing issues in sst/opencode using the gh cli to find issues matching this query: +Search through existing issues in anomalyco/opencode using the gh cli to find issues matching this query: $ARGUMENTS diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 6008ab9bc0c..5d2dec625c6 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -10,11 +10,6 @@ "options": {}, }, }, - "permission": { - "bash": { - "ls foo": "ask", - }, - }, "mcp": { "context7": { "type": "remote", @@ -23,5 +18,6 @@ }, "tools": { "github-triage": false, + "github-pr-search": false, }, } diff --git a/.opencode/tool/github-pr-search.ts b/.opencode/tool/github-pr-search.ts new file mode 100644 index 00000000000..587fdfaaf28 --- /dev/null +++ b/.opencode/tool/github-pr-search.ts @@ -0,0 +1,57 @@ +/// +import { tool } from "@opencode-ai/plugin" +import DESCRIPTION from "./github-pr-search.txt" + +async function githubFetch(endpoint: string, options: RequestInit = {}) { + const response = await fetch(`https://api.github.com${endpoint}`, { + ...options, + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + ...options.headers, + }, + }) + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`) + } + return response.json() +} + +interface PR { + title: string + html_url: string +} + +export default tool({ + description: DESCRIPTION, + args: { + query: tool.schema.string().describe("Search query for PR titles and descriptions"), + limit: tool.schema.number().describe("Maximum number of results to return").default(10), + offset: tool.schema.number().describe("Number of results to skip for pagination").default(0), + }, + async execute(args) { + const owner = "anomalyco" + const repo = "opencode" + + const page = Math.floor(args.offset / args.limit) + 1 + const searchQuery = encodeURIComponent(`${args.query} repo:${owner}/${repo} type:pr state:open`) + const result = await githubFetch( + `/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`, + ) + + if (result.total_count === 0) { + return `No PRs found matching "${args.query}"` + } + + const prs = result.items as PR[] + + if (prs.length === 0) { + return `No other PRs found matching "${args.query}"` + } + + const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n") + + return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}` + }, +}) diff --git a/.opencode/tool/github-pr-search.txt b/.opencode/tool/github-pr-search.txt new file mode 100644 index 00000000000..28d8643f13c --- /dev/null +++ b/.opencode/tool/github-pr-search.txt @@ -0,0 +1,10 @@ +Use this tool to search GitHub pull requests by title and description. + +This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including: +- PR number and title +- Author +- State (open/closed/merged) +- Labels +- Description snippet + +Use the query parameter to search for keywords that might appear in PR titles or descriptions. diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index a5e6c811d83..1e216f1c8da 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -40,7 +40,7 @@ export default tool({ async execute(args) { const issue = getIssueNumber() // const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }) - const owner = "sst" + const owner = "anomalyco" const repo = "opencode" const results: string[] = [] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ee9ae4303f..08ab0159c2a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,8 +67,49 @@ Replace `` with your platform (e.g., `darwin-arm64`, `linux-x64`). - Core pieces: - `packages/opencode`: OpenCode core business logic & server. - `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui) + - `packages/app`: The shared web UI components, written in SolidJS + - `packages/desktop`: The native desktop app, built with Tauri (wraps `packages/app`) - `packages/plugin`: Source for `@opencode-ai/plugin` +### Running the Web App + +To test UI changes during development, run the web app: + +```bash +bun run --cwd packages/app dev +``` + +This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here. + +### Running the Desktop App + +The desktop app is a native Tauri application that wraps the web UI. + +To run the native desktop app: + +```bash +bun run --cwd packages/desktop tauri dev +``` + +This starts the web dev server on http://localhost:1420 and opens the native window. + +If you only want the web dev server (no native shell): + +```bash +bun run --cwd packages/desktop dev +``` + +To create a production `dist/` and build the native app bundle: + +```bash +bun run --cwd packages/desktop tauri build +``` + +This runs `bun run --cwd packages/desktop build` automatically via Tauri’s `beforeBuildCommand`. + +> [!NOTE] +> Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions. + > [!NOTE] > If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files. diff --git a/README.md b/README.md index a5b35466c5a..04c7b53e518 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,7 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ### Agents -OpenCode includes two built-in agents you can switch between, -you can switch between these using the `Tab` key. +OpenCode includes two built-in agents you can switch between with the `Tab` key. - **build** - Default, full access agent for development work - **plan** - Read-only agent for analysis and code exploration @@ -108,10 +107,6 @@ It's very similar to Claude Code in terms of capability. Here are the key differ - A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal. - A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients. -#### What's the other repo? - -The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466). - --- **Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000000..c0d67a4abea --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,115 @@ +

+ + + + + OpenCode logo + + +

+

开源的 AI Coding Agent。

+

+ Discord + npm + Build status +

+ +[![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 bucket add extras; scoop install extras/opencode # Windows +choco install opencode # Windows +brew install opencode # macOS 和 Linux +paru -S opencode-bin # Arch Linux +mise use -g opencode # 任意系统 +nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支 +``` + +> [!TIP] +> 安装前请先移除 0.1.x 之前的旧版本。 + +### 桌面应用程序 (BETA) + +OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](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 Cask) +brew install --cask opencode-desktop +``` + +#### 安装目录 + +安装脚本按照以下优先级决定安装路径: + +1. `$OPENCODE_INSTALL_DIR` - 自定义安装目录 +2. `$XDG_BIN_DIR` - 符合 XDG 基础目录规范的路径 +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 +``` + +### Agents + +OpenCode 内置两种 Agent,可用 `Tab` 键快速切换: + +- **build** - 默认模式,具备完整权限,适合开发工作 +- **plan** - 只读模式,适合代码分析与探索 + - 默认拒绝修改文件 + - 运行 bash 命令前会询问 + - 便于探索未知代码库或规划改动 + +另外还包含一个 **general** 子 Agent,用于复杂搜索和多步任务,内部使用,也可在消息中输入 `@general` 调用。 + +了解更多 [Agents](https://opencode.ai/docs/agents) 相关信息。 + +### 文档 + +更多配置说明请查看我们的 [**官方文档**](https://opencode.ai/docs)。 + +### 参与贡献 + +如有兴趣贡献代码,请在提交 PR 前阅读 [贡献指南 (Contributing Docs)](./CONTRIBUTING.md)。 + +### 基于 OpenCode 进行开发 + +如果你在项目名中使用了 “opencode”(如 “opencode-dashboard” 或 “opencode-mobile”),请在 README 里注明该项目不是 OpenCode 团队官方开发,且不存在隶属关系。 + +### 常见问题 (FAQ) + +#### 这和 Claude Code 有什么不同? + +功能上很相似,关键差异: + +- 100% 开源。 +- 不绑定特定提供商。推荐使用 [OpenCode Zen](https://opencode.ai/zen) 的模型,但也可搭配 Claude、OpenAI、Google 甚至本地模型。模型迭代会缩小差异、降低成本,因此保持 provider-agnostic 很重要。 +- 内置 LSP 支持。 +- 聚焦终端界面 (TUI)。OpenCode 由 Neovim 爱好者和 [terminal.shop](https://terminal.shop) 的创建者打造,会持续探索终端的极限。 +- 客户端/服务器架构。可在本机运行,同时用移动设备远程驱动。TUI 只是众多潜在客户端之一。 + +#### 另一个同名的仓库是什么? + +另一个名字相近的仓库与本项目无关。[点击这里了解背后故事](https://x.com/thdxr/status/1933561254481666466)。 + +--- + +**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/STATS.md b/STATS.md index e67d56648f7..9ee486d20ed 100644 --- a/STATS.md +++ b/STATS.md @@ -1,191 +1,196 @@ # Download Stats -| Date | GitHub Downloads | npm Downloads | Total | -| ---------- | ------------------- | ------------------- | ------------------- | -| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) | -| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) | -| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) | -| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) | -| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) | -| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) | -| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) | -| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) | -| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) | -| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) | -| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) | -| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) | -| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) | -| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) | -| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) | -| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) | -| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) | -| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) | -| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) | -| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) | -| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) | -| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) | -| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) | -| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) | -| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) | -| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) | -| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) | -| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) | -| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) | -| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) | -| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) | -| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) | -| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) | -| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) | -| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) | -| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) | -| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) | -| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) | -| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) | -| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) | -| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) | -| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) | -| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) | -| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) | -| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) | -| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) | -| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) | -| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) | -| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) | -| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) | -| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) | -| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) | -| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) | -| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) | -| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) | -| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) | -| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) | -| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) | -| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) | -| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) | -| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) | -| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) | -| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) | -| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) | -| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) | -| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) | -| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) | -| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) | -| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) | -| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) | -| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) | -| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) | -| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) | -| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) | -| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) | -| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) | -| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) | -| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) | -| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) | -| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) | -| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) | -| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) | -| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) | -| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) | -| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) | -| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) | -| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) | -| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) | -| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) | -| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) | -| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) | -| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) | -| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) | -| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) | -| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) | -| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) | -| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) | -| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) | -| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) | -| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) | -| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) | -| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) | -| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) | -| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) | -| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) | -| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) | -| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) | -| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) | -| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) | -| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) | -| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) | -| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) | -| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) | -| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) | -| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) | -| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) | -| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) | -| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) | -| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) | -| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) | -| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) | -| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) | -| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) | -| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) | -| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) | -| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) | -| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) | -| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) | -| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) | -| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) | -| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) | -| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) | -| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) | -| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) | -| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) | -| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) | -| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) | -| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) | -| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) | -| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) | -| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) | -| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) | -| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) | -| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) | -| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) | -| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) | -| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) | -| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) | -| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) | -| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) | -| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) | -| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) | -| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) | -| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) | -| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) | -| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) | -| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) | -| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) | -| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) | -| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) | -| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) | -| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) | -| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) | -| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) | -| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) | -| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) | -| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) | -| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) | -| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) | -| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) | -| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) | -| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) | -| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) | -| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) | -| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) | -| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) | -| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) | -| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) | -| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) | -| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) | -| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) | -| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) | -| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) | -| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) | -| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) | -| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) | -| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) | +| Date | GitHub Downloads | npm Downloads | Total | +| ---------- | -------------------- | ------------------- | -------------------- | +| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) | +| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) | +| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) | +| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) | +| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) | +| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) | +| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) | +| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) | +| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) | +| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) | +| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) | +| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) | +| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) | +| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) | +| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) | +| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) | +| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) | +| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) | +| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) | +| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) | +| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) | +| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) | +| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) | +| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) | +| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) | +| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) | +| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) | +| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) | +| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) | +| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) | +| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) | +| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) | +| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) | +| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) | +| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) | +| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) | +| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) | +| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) | +| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) | +| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) | +| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) | +| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) | +| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) | +| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) | +| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) | +| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) | +| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) | +| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) | +| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) | +| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) | +| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) | +| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) | +| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) | +| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) | +| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) | +| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) | +| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) | +| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) | +| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) | +| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) | +| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) | +| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) | +| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) | +| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) | +| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) | +| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) | +| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) | +| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) | +| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) | +| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) | +| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) | +| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) | +| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) | +| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) | +| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) | +| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) | +| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) | +| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) | +| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) | +| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) | +| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) | +| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) | +| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) | +| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) | +| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) | +| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) | +| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) | +| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) | +| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) | +| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) | +| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) | +| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) | +| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) | +| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) | +| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) | +| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) | +| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) | +| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) | +| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) | +| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) | +| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) | +| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) | +| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) | +| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) | +| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) | +| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) | +| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) | +| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) | +| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) | +| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) | +| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) | +| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) | +| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) | +| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) | +| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) | +| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) | +| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) | +| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) | +| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) | +| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) | +| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) | +| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) | +| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) | +| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) | +| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) | +| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) | +| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) | +| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) | +| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) | +| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) | +| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) | +| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) | +| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) | +| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) | +| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) | +| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) | +| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) | +| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) | +| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) | +| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) | +| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) | +| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) | +| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) | +| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) | +| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) | +| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) | +| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) | +| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) | +| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) | +| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) | +| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) | +| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) | +| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) | +| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) | +| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) | +| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) | +| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) | +| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) | +| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) | +| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) | +| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) | +| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) | +| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) | +| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) | +| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) | +| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) | +| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) | +| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) | +| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) | +| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) | +| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) | +| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) | +| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) | +| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) | +| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) | +| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) | +| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) | +| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) | +| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) | +| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) | +| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) | +| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) | +| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) | +| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) | +| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) | +| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) | +| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) | +| 2026-01-03 | 1,618,065 (+54,591) | 1,331,914 (+10,955) | 2,949,979 (+65,546) | +| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) | +| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) | +| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) | +| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) | diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 164f69bd46c..47d008fb423 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -1,9 +1,7 @@ ## Style Guide - Try to keep things in one function unless composable or reusable -- DO NOT do unnecessary destructuring of variables -- DO NOT use `else` statements unless necessary -- DO NOT use `try`/`catch` if it can be avoided +- AVOID unnecessary destructuring of variables - AVOID `try`/`catch` where possible - AVOID `else` statements - AVOID using `any` type diff --git a/bun.lock b/bun.lock index 329437fef08..84cb3b37489 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -70,7 +70,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -98,7 +98,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -125,7 +125,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -149,7 +149,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -173,9 +173,10 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "@opencode-ai/app": "workspace:*", + "@opencode-ai/ui": "workspace:*", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", @@ -201,7 +202,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -230,7 +231,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -246,7 +247,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.224", + "version": "1.1.4", "bin": { "opencode": "./bin/opencode", }, @@ -285,11 +286,12 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.2", - "@opentui/core": "0.1.67", - "@opentui/solid": "0.1.67", + "@opentui/core": "0.1.69", + "@opentui/solid": "0.1.69", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", @@ -348,7 +350,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -368,7 +370,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.224", + "version": "1.1.4", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -379,7 +381,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -392,7 +394,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -400,6 +402,7 @@ "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", + "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", "@typescript/native-preview": "catalog:", @@ -430,7 +433,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "zod": "catalog:", }, @@ -441,7 +444,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.224", + "version": "1.1.4", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1196,21 +1199,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.67", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.67", "@opentui/core-darwin-x64": "0.1.67", "@opentui/core-linux-arm64": "0.1.67", "@opentui/core-linux-x64": "0.1.67", "@opentui/core-win32-arm64": "0.1.67", "@opentui/core-win32-x64": "0.1.67", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-zmfyA10QUbzT6ohacPoHmGiYzuJrDSCfQWRWrKtao0BrHj9bii73qWy3V/eR4ibVueoRREwxJs5GlBOSvK6IoA=="], + "@opentui/core": ["@opentui/core@0.1.69", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.69", "@opentui/core-darwin-x64": "0.1.69", "@opentui/core-linux-arm64": "0.1.69", "@opentui/core-linux-x64": "0.1.69", "@opentui/core-win32-arm64": "0.1.69", "@opentui/core-win32-x64": "0.1.69", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-BcEFnAuMq4vgfb+zxOP/l+NO1AS3fVHkYjn+E8Wpmaxr0AzWNTi2NPAMtQf+Wqufxo0NYh0gY4c9B6n8OxTjGw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.67", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LtOcTlFD+kO7neItmkiF77H8cnjTYzBOZe8JQGwRSt9aaCke3UzMvLxmQnj4BP/kPC3hi9V6NRnFdptz0sJZIQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.69", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d9RPAh84O2XIyMw+7+X0fEyi+4KH5sPk9AxLze8GHRBGOzkRunqagFCLBrN5VFs2e2nbhIYtjMszo7gcpWyh7g=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.67", "", { "os": "darwin", "cpu": "x64" }, "sha512-9i+awVWgpEVqZhFLaLq8usNGyCiyT5QxMLy6eH7JmRic79S34u23HfxiniGRtdYh3aqpm9SbLzo60v0nRIUkCA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.69", "", { "os": "darwin", "cpu": "x64" }, "sha512-41K9zkL2IG0ahL+8Gd+e9ulMrnJF6lArPzG7grjWzo+FWEZwvw0WLCO1/Gn5K85G8Yx7gQXkZOUaw1BmHjxoRw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.67", "", { "os": "linux", "cpu": "arm64" }, "sha512-WLjnTM3Ig//SRo0FUZYZJ5TITVbR6dKDVg6axU2D+sMoUzJMBP/Xo04q/TvZ3wP764Yca9l7oVMKWDxHlygyjQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.69", "", { "os": "linux", "cpu": "arm64" }, "sha512-IcUjwjuIpX3BBG1a9kjMqWrHYCFHAVfjh5nIRozWZZoqaczLzJb3nJeF2eg8aDeIoGhXvERWB1r1gmqPW8u3vQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.67", "", { "os": "linux", "cpu": "x64" }, "sha512-5UbZ/TqWi/DAmHIZL4NvhdpgTwglszRiddkRiQ8cT0IbnE4lutd4XxWUWcLKwsNT1YJv32TtcGWkuthluLiriQ=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.69", "", { "os": "linux", "cpu": "x64" }, "sha512-5S9vqEIq7q+MEdp4cT0HLegBWu0pWLcletHZL80bsLbJt9OT8en3sQmL5bvas9sIuyeBFru9bfCmrQ/gnVTTiA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.67", "", { "os": "win32", "cpu": "arm64" }, "sha512-KNam5rObhN8/U9+GVVuvtAlGXp3MfdMHnw4W2P6YH7xp8HTsLvABUT91SJEyJ/ktVe9e1itLDG2fDHSoA5NbUg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.69", "", { "os": "win32", "cpu": "arm64" }, "sha512-eSKcGwbcnJJPtrTFJI7STZ7inSYeedHS0swwjZhh9SADAruEz08intamunOslffv5+mnlvRp7UBGK35cMjbv/w=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.67", "", { "os": "win32", "cpu": "x64" }, "sha512-740lkOw42zLNh9YfahXjCwV2DS/amH2uMDh3tCADDCLckrMhemIhqArXDiMlalDxDqYspoaZCpBsFVsG9dMS6A=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.69", "", { "os": "win32", "cpu": "x64" }, "sha512-OjG/0jqYXURqbbUwNgSPrBA6yuKF3OOFh8JSG7VvzoYHJFJRmwVWY0fztWv/hgGHe354ti37c7JDJBQ44HOCdA=="], - "@opentui/solid": ["@opentui/solid@0.1.67", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.67", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-dVNq0+PJIdNb63D0T7vcbyVF/ZvLCihGvivTU50zDOzd0Sk5prbrIfpG8+DjMErFubXfdZQvdy/PqFdtw0rjtQ=="], + "@opentui/solid": ["@opentui/solid@0.1.69", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.69", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-ls589N8P9gvcNW8uF+Il4xisF5Uouk0RRmSaLdzmItNJSW5J9Y0nPtMELta6hBp0yIRAurWUO1wtkKXVF+eaxg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -1616,6 +1619,8 @@ "@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ=="], + "@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="], + "@solid-primitives/scroll": ["@solid-primitives/scroll@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ejq/Z7zKo/6eIEFr1bFLzXFxiGBCMLuqCM8QB8urr3YdPzjSETFLzYRWUyRiDWaBQN0F7k0SY6S7ig5nWOP7vg=="], "@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw=="], diff --git a/flake.lock b/flake.lock index f347380cec2..644277dbb3f 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1767273430, - "narHash": "sha256-kDpoFwQ8GLrPiS3KL+sAwreXrph2KhdXuJzo5+vSLoo=", + "lastModified": 1767364772, + "narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "76eec3925eb9bbe193934987d3285473dbcfad50", + "rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index a578da9c1f7..e53053217fd 100644 --- a/flake.nix +++ b/flake.nix @@ -66,10 +66,10 @@ mkNodeModules = pkgs.callPackage ./nix/node-modules.nix { hash = nodeModulesHash; }; - mkPackage = pkgs.callPackage ./nix/opencode.nix { }; - in - { - default = mkPackage { + mkOpencode = pkgs.callPackage ./nix/opencode.nix { }; + mkDesktop = pkgs.callPackage ./nix/desktop.nix { }; + + opencodePkg = mkOpencode { inherit (packageJson) version; src = ./.; scripts = ./nix/scripts; @@ -77,6 +77,18 @@ modelsDev = "${modelsDev.${system}}/dist/_api.json"; inherit mkNodeModules; }; + + desktopPkg = mkDesktop { + inherit (packageJson) version; + src = ./.; + scripts = ./nix/scripts; + mkNodeModules = mkNodeModules; + opencode = opencodePkg; + }; + in + { + default = opencodePkg; + desktop = desktopPkg; } ); diff --git a/github/README.md b/github/README.md index 24651a09f9d..954710f234a 100644 --- a/github/README.md +++ b/github/README.md @@ -82,7 +82,7 @@ This will walk you through installing the GitHub app, creating the workflow, and id-token: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 diff --git a/infra/console.ts b/infra/console.ts index c69a706838e..1e584ca576c 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -76,6 +76,7 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", "checkout.session.completed", "checkout.session.expired", "charge.refunded", + "invoice.payment_succeeded", "customer.created", "customer.deleted", "customer.updated", @@ -97,6 +98,19 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", ], }) +const zenProduct = new stripe.Product("ZenBlack", { + name: "OpenCode Black", +}) +const zenPrice = new stripe.Price("ZenBlackPrice", { + product: zenProduct.id, + unitAmount: 20000, + currency: "usd", + recurring: { + interval: "month", + intervalCount: 1, + }, +}) + const ZEN_MODELS = [ new sst.Secret("ZEN_MODELS1"), new sst.Secret("ZEN_MODELS2"), @@ -104,7 +118,9 @@ const ZEN_MODELS = [ new sst.Secret("ZEN_MODELS4"), new sst.Secret("ZEN_MODELS5"), new sst.Secret("ZEN_MODELS6"), + new sst.Secret("ZEN_MODELS7"), ] +const ZEN_BLACK = new sst.Secret("ZEN_BLACK") const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { properties: { value: auth.url.apply((url) => url!) }, @@ -146,6 +162,7 @@ new sst.cloudflare.x.SolidStart("Console", { EMAILOCTOPUS_API_KEY, AWS_SES_ACCESS_KEY_ID, AWS_SES_SECRET_ACCESS_KEY, + ZEN_BLACK, ...ZEN_MODELS, ...($dev ? [ diff --git a/install b/install index f995e2d4306..757694481c4 100755 --- a/install +++ b/install @@ -16,16 +16,19 @@ Usage: install.sh [options] Options: -h, --help Display this help message -v, --version Install a specific version (e.g., 1.0.180) + -b, --binary Install from a local binary instead of downloading --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) Examples: curl -fsSL https://opencode.ai/install | bash curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180 + ./install --binary /path/to/opencode EOF } requested_version=${VERSION:-} no_modify_path=false +binary_path="" while [[ $# -gt 0 ]]; do case "$1" in @@ -42,6 +45,15 @@ while [[ $# -gt 0 ]]; do exit 1 fi ;; + -b|--binary) + if [[ -n "${2:-}" ]]; then + binary_path="$2" + shift 2 + else + echo -e "${RED}Error: --binary requires a path argument${NC}" + exit 1 + fi + ;; --no-modify-path) no_modify_path=true shift @@ -53,119 +65,128 @@ while [[ $# -gt 0 ]]; do esac done -raw_os=$(uname -s) -os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]') -case "$raw_os" in - Darwin*) os="darwin" ;; - Linux*) os="linux" ;; - MINGW*|MSYS*|CYGWIN*) os="windows" ;; -esac - -arch=$(uname -m) -if [[ "$arch" == "aarch64" ]]; then - arch="arm64" -fi -if [[ "$arch" == "x86_64" ]]; then - arch="x64" -fi +INSTALL_DIR=$HOME/.opencode/bin +mkdir -p "$INSTALL_DIR" -if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then - rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0) - if [ "$rosetta_flag" = "1" ]; then - arch="arm64" - fi -fi +# If --binary is provided, skip all download/detection logic +if [ -n "$binary_path" ]; then + if [ ! -f "$binary_path" ]; then + echo -e "${RED}Error: Binary not found at ${binary_path}${NC}" + exit 1 + fi + specific_version="local" +else + raw_os=$(uname -s) + os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]') + case "$raw_os" in + Darwin*) os="darwin" ;; + Linux*) os="linux" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + esac -combo="$os-$arch" -case "$combo" in - linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64) - ;; - *) - echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}" - exit 1 - ;; -esac + arch=$(uname -m) + if [[ "$arch" == "aarch64" ]]; then + arch="arm64" + fi + if [[ "$arch" == "x86_64" ]]; then + arch="x64" + fi -archive_ext=".zip" -if [ "$os" = "linux" ]; then - archive_ext=".tar.gz" -fi + if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then + rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0) + if [ "$rosetta_flag" = "1" ]; then + arch="arm64" + fi + fi -is_musl=false -if [ "$os" = "linux" ]; then - if [ -f /etc/alpine-release ]; then - is_musl=true - fi + combo="$os-$arch" + case "$combo" in + linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64) + ;; + *) + echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}" + exit 1 + ;; + esac - if command -v ldd >/dev/null 2>&1; then - if ldd --version 2>&1 | grep -qi musl; then - is_musl=true + archive_ext=".zip" + if [ "$os" = "linux" ]; then + archive_ext=".tar.gz" fi - fi -fi -needs_baseline=false -if [ "$arch" = "x64" ]; then - if [ "$os" = "linux" ]; then - if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then - needs_baseline=true - fi - fi + is_musl=false + if [ "$os" = "linux" ]; then + if [ -f /etc/alpine-release ]; then + is_musl=true + fi - if [ "$os" = "darwin" ]; then - avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0) - if [ "$avx2" != "1" ]; then - needs_baseline=true + if command -v ldd >/dev/null 2>&1; then + if ldd --version 2>&1 | grep -qi musl; then + is_musl=true + fi + fi fi - fi -fi - -target="$os-$arch" -if [ "$needs_baseline" = "true" ]; then - target="$target-baseline" -fi -if [ "$is_musl" = "true" ]; then - target="$target-musl" -fi -filename="$APP-$target$archive_ext" + needs_baseline=false + if [ "$arch" = "x64" ]; then + if [ "$os" = "linux" ]; then + if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then + needs_baseline=true + fi + fi + if [ "$os" = "darwin" ]; then + avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0) + if [ "$avx2" != "1" ]; then + needs_baseline=true + fi + fi + fi -if [ "$os" = "linux" ]; then - if ! command -v tar >/dev/null 2>&1; then - echo -e "${RED}Error: 'tar' is required but not installed.${NC}" - exit 1 + target="$os-$arch" + if [ "$needs_baseline" = "true" ]; then + target="$target-baseline" fi -else - if ! command -v unzip >/dev/null 2>&1; then - echo -e "${RED}Error: 'unzip' is required but not installed.${NC}" - exit 1 + if [ "$is_musl" = "true" ]; then + target="$target-musl" fi -fi -INSTALL_DIR=$HOME/.opencode/bin -mkdir -p "$INSTALL_DIR" + filename="$APP-$target$archive_ext" -if [ -z "$requested_version" ]; then - url="https://github.com/anomalyco/opencode/releases/latest/download/$filename" - specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') - if [[ $? -ne 0 || -z "$specific_version" ]]; then - echo -e "${RED}Failed to fetch version information${NC}" - exit 1 + if [ "$os" = "linux" ]; then + if ! command -v tar >/dev/null 2>&1; then + echo -e "${RED}Error: 'tar' is required but not installed.${NC}" + exit 1 + fi + else + if ! command -v unzip >/dev/null 2>&1; then + echo -e "${RED}Error: 'unzip' is required but not installed.${NC}" + exit 1 + fi fi -else - # Strip leading 'v' if present - requested_version="${requested_version#v}" - url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename" - specific_version=$requested_version - - # Verify the release exists before downloading - http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}") - if [ "$http_status" = "404" ]; then - echo -e "${RED}Error: Release v${requested_version} not found${NC}" - echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}" - exit 1 + + if [ -z "$requested_version" ]; then + url="https://github.com/anomalyco/opencode/releases/latest/download/$filename" + specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') + + if [[ $? -ne 0 || -z "$specific_version" ]]; then + echo -e "${RED}Failed to fetch version information${NC}" + exit 1 + fi + else + # Strip leading 'v' if present + requested_version="${requested_version#v}" + url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename" + specific_version=$requested_version + + # Verify the release exists before downloading + http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}") + if [ "$http_status" = "404" ]; then + echo -e "${RED}Error: Release v${requested_version} not found${NC}" + echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}" + exit 1 + fi fi fi @@ -187,11 +208,8 @@ check_version() { if command -v opencode >/dev/null 2>&1; then opencode_path=$(which opencode) - - ## TODO: check if version is installed - # installed_version=$(opencode version) - installed_version="0.0.1" - installed_version=$(echo $installed_version | awk '{print $2}') + ## Check the installed version + installed_version=$(opencode --version 2>/dev/null || echo "") if [[ "$installed_version" != "$specific_version" ]]; then print_message info "${MUTED}Installed version: ${NC}$installed_version." @@ -267,11 +285,11 @@ download_with_progress() { { local length=0 local bytes=0 - + while IFS=" " read -r -a line; do [ "${#line[@]}" -lt 2 ] && continue local tag="${line[0]} ${line[1]}" - + if [ "$tag" = "0000: content-length:" ]; then length="${line[2]}" length=$(echo "$length" | tr -d '\r') @@ -296,7 +314,7 @@ download_and_install() { print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version" local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$" mkdir -p "$tmp_dir" - + if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then # Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails curl -# -L -o "$tmp_dir/$filename" "$url" @@ -307,14 +325,24 @@ download_and_install() { else unzip -q "$tmp_dir/$filename" -d "$tmp_dir" fi - + mv "$tmp_dir/opencode" "$INSTALL_DIR" chmod 755 "${INSTALL_DIR}/opencode" rm -rf "$tmp_dir" } -check_version -download_and_install +install_from_binary() { + print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}from: ${NC}$binary_path" + cp "$binary_path" "${INSTALL_DIR}/opencode" + chmod 755 "${INSTALL_DIR}/opencode" +} + +if [ -n "$binary_path" ]; then + install_from_binary +else + check_version + download_and_install +fi add_to_path() { @@ -416,4 +444,3 @@ echo -e "" echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs" echo -e "" echo -e "" - diff --git a/nix/desktop.nix b/nix/desktop.nix new file mode 100644 index 00000000000..4b659413aaa --- /dev/null +++ b/nix/desktop.nix @@ -0,0 +1,145 @@ +{ + lib, + stdenv, + rustPlatform, + bun, + pkg-config, + dbus ? null, + openssl, + glib ? null, + gtk3 ? null, + libsoup_3 ? null, + webkitgtk_4_1 ? null, + librsvg ? null, + libappindicator-gtk3 ? null, + cargo, + rustc, + makeBinaryWrapper, + nodejs, + jq, +}: +args: +let + scripts = args.scripts; + mkModules = + attrs: + args.mkNodeModules ( + attrs + // { + canonicalizeScript = scripts + "/canonicalize-node-modules.ts"; + normalizeBinsScript = scripts + "/normalize-bun-binaries.ts"; + } + ); +in +rustPlatform.buildRustPackage rec { + pname = "opencode-desktop"; + version = args.version; + + src = args.src; + + # We need to set the root for cargo, but we also need access to the whole repo. + postUnpack = '' + # Update sourceRoot to point to the tauri app + sourceRoot+=/packages/desktop/src-tauri + ''; + + cargoLock = { + lockFile = ../packages/desktop/src-tauri/Cargo.lock; + allowBuiltinFetchGit = true; + }; + + node_modules = mkModules { + version = version; + src = src; + }; + + nativeBuildInputs = [ + pkg-config + bun + makeBinaryWrapper + cargo + rustc + nodejs + jq + ]; + + buildInputs = [ + openssl + ] + ++ lib.optionals stdenv.isLinux [ + dbus + glib + gtk3 + libsoup_3 + webkitgtk_4_1 + librsvg + libappindicator-gtk3 + ]; + + preBuild = '' + # Restore node_modules + pushd ../../.. + + # Copy node_modules from the fixed-output derivation + # We use cp -r --no-preserve=mode to ensure we can write to them if needed, + # though we usually just read. + cp -r ${node_modules}/node_modules . + cp -r ${node_modules}/packages . + + # Ensure node_modules is writable so patchShebangs can update script headers + chmod -R u+w node_modules + # Ensure workspace packages are writable for tsgo incremental outputs (.tsbuildinfo) + chmod -R u+w packages + # Patch shebangs so scripts can run + patchShebangs node_modules + + # Copy sidecar + mkdir -p packages/desktop/src-tauri/sidecars + targetTriple=${stdenv.hostPlatform.rust.rustcTarget} + cp ${args.opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-$targetTriple + + # Merge prod config into tauri.conf.json + if ! jq -s '.[0] * .[1]' \ + packages/desktop/src-tauri/tauri.conf.json \ + packages/desktop/src-tauri/tauri.prod.conf.json \ + > packages/desktop/src-tauri/tauri.conf.json.tmp; then + echo "Error: failed to merge tauri.conf.json with tauri.prod.conf.json" >&2 + exit 1 + fi + mv packages/desktop/src-tauri/tauri.conf.json.tmp packages/desktop/src-tauri/tauri.conf.json + + # Build the frontend + cd packages/desktop + + # The 'build' script runs 'bun run typecheck && vite build'. + bun run build + + popd + ''; + + # Tauri bundles the assets during the rust build phase (which happens after preBuild). + # It looks for them in the location specified in tauri.conf.json. + + postInstall = lib.optionalString stdenv.isLinux '' + # Wrap the binary to ensure it finds the libraries + wrapProgram $out/bin/opencode-desktop \ + --prefix LD_LIBRARY_PATH : ${ + lib.makeLibraryPath [ + gtk3 + webkitgtk_4_1 + librsvg + glib + libsoup_3 + ] + } + ''; + + meta = with lib; { + description = "OpenCode Desktop App"; + homepage = "https://opencode.ai"; + license = licenses.mit; + maintainers = with maintainers; [ ]; + mainProgram = "opencode-desktop"; + platforms = platforms.linux ++ platforms.darwin; + }; +} diff --git a/nix/hashes.json b/nix/hashes.json index d578992a984..e6d0b5ead07 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-uJDhOieOdMQLORyuOWtgtjLoMnNEQPrDcyij9TX0aTw=" + "nodeModules": "sha256-WHqX159BYPSHBFmxxkTrWPytBzTSTcWkoEywAxP58kI=" } diff --git a/nix/scripts/bun-build.ts b/nix/scripts/bun-build.ts index a227081639d..e607676cb11 100644 --- a/nix/scripts/bun-build.ts +++ b/nix/scripts/bun-build.ts @@ -60,7 +60,12 @@ const result = await Bun.build({ compile: { target, outfile: "opencode", - execArgv: ["--user-agent=opencode/" + version, '--env-file=""', "--"], + autoloadBunfig: false, + autoloadDotenv: false, + //@ts-ignore (bun types aren't up to date) + autoloadTsconfig: true, + autoloadPackageJson: true, + execArgv: ["--user-agent=opencode/" + version, "--use-system-ca", "--"], windows: {}, }, }) diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index ca19456fec6..98b681bca8a 100644 --- a/packages/app/AGENTS.md +++ b/packages/app/AGENTS.md @@ -1,6 +1,6 @@ ## Debugging -- To test the opencode app, use the playwrite mcp server, the app is already +- To test the opencode app, use the playwright MCP server, the app is already running at http://localhost:3000 - NEVER try to restart the app, or the server process, EVER. diff --git a/packages/app/README.md b/packages/app/README.md index 6a176453668..bd10e6c8ddf 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -1,8 +1,8 @@ ## Usage -Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`. +Dependencies for these templates are managed with [pnpm](https://pnpm.io) using `pnpm up -Lri`. -This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template. +This is the reason you see a `pnpm-lock.yaml`. That said, any package manager will work. This file can safely be removed once you clone a template. ```bash $ npm install # or pnpm install or yarn install diff --git a/packages/app/index.html b/packages/app/index.html index ea423780448..44fa3b989dc 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -47,7 +47,7 @@ -
+
diff --git a/packages/app/package.json b/packages/app/package.json index 0a6a7151a96..62eb1b055a5 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.0.224", + "version": "1.1.4", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts index ad165f43f75..7f6780557da 100644 --- a/packages/app/src/addons/serialize.test.ts +++ b/packages/app/src/addons/serialize.test.ts @@ -242,6 +242,53 @@ describe("SerializeAddon", () => { expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42") }) + test("serialized output should restore after Terminal.reset()", async () => { + const { term, addon } = createTerminal() + + const content = [ + "\x1b[1;32m❯\x1b[0m \x1b[34mcd\x1b[0m /some/path", + "\x1b[1;32m❯\x1b[0m \x1b[34mls\x1b[0m -la", + "total 42", + ].join("\r\n") + + await writeAndWait(term, content) + + const serialized = addon.serialize() + + const { term: term2 } = createTerminal() + terminals.push(term2) + term2.reset() + await writeAndWait(term2, serialized) + + expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path") + expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la") + expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42") + }) + + test("alternate buffer should round-trip without garbage", async () => { + const { term, addon } = createTerminal(20, 5) + + await writeAndWait(term, "normal\r\n") + await writeAndWait(term, "\x1b[?1049h\x1b[HALT") + + expect(term.buffer.active.type).toBe("alternate") + + const serialized = addon.serialize() + + const { term: term2 } = createTerminal(20, 5) + terminals.push(term2) + await writeAndWait(term2, serialized) + + expect(term2.buffer.active.type).toBe("alternate") + + const line = term2.buffer.active.getLine(0) + expect(line?.translateToString(true)).toBe("ALT") + + // Ensure a cell beyond content isn't garbage + const cellCode = line?.getCell(10)?.getCode() + expect(cellCode === 0 || cellCode === 32).toBe(true) + }) + test("serialized output written to new terminal should match original colors", async () => { const { term, addon } = createTerminal(40, 5) diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts index cb1ff84423f..4309a725e51 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -157,23 +157,6 @@ function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean { abstract class BaseSerializeHandler { constructor(protected readonly _buffer: IBuffer) {} - private _isRealContent(codepoint: number): boolean { - if (codepoint === 0) return false - if (codepoint >= 0xf000) return false - return true - } - - private _findLastContentColumn(line: IBufferLine): number { - let lastContent = -1 - for (let col = 0; col < line.length; col++) { - const cell = line.getCell(col) - if (cell && this._isRealContent(cell.getCode())) { - lastContent = col - } - } - return lastContent + 1 - } - public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string { let oldCell = this._buffer.getNullCell() @@ -182,14 +165,14 @@ abstract class BaseSerializeHandler { const startColumn = range.start.x const endColumn = range.end.x - this._beforeSerialize(endRow - startRow, startRow, endRow) + this._beforeSerialize(endRow - startRow + 1, startRow, endRow) for (let row = startRow; row <= endRow; row++) { const line = this._buffer.getLine(row) if (line) { const startLineColumn = row === range.start.y ? startColumn : 0 - const maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line) - const endLineColumn = Math.min(maxColumn, line.length) + const endLineColumn = Math.min(endColumn, line.length) + for (let col = startLineColumn; col < endLineColumn; col++) { const c = line.getCell(col) if (!c) { @@ -243,6 +226,13 @@ class StringSerializeHandler extends BaseSerializeHandler { protected _beforeSerialize(rows: number, start: number, _end: number): void { this._allRows = new Array(rows) + this._allRowSeparators = new Array(rows) + this._rowIndex = 0 + + this._currentRow = "" + this._nullCellCount = 0 + this._cursorStyle = this._buffer.getNullCell() + this._lastContentCursorRow = start this._lastCursorRow = start this._firstRow = start @@ -251,6 +241,11 @@ class StringSerializeHandler extends BaseSerializeHandler { protected _rowEnd(row: number, isLastRow: boolean): void { let rowSeparator = "" + if (this._nullCellCount > 0) { + this._currentRow += " ".repeat(this._nullCellCount) + this._nullCellCount = 0 + } + if (!isLastRow) { const nextLine = this._buffer.getLine(row + 1) @@ -388,7 +383,8 @@ class StringSerializeHandler extends BaseSerializeHandler { } const codepoint = cell.getCode() - const isGarbage = codepoint >= 0xf000 + const isInvalidCodepoint = codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff) + const isGarbage = isInvalidCodepoint || (codepoint >= 0xf000 && cell.getWidth() === 1) const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage const sgrSeq = this._diffStyle(cell, this._cursorStyle) @@ -397,7 +393,7 @@ class StringSerializeHandler extends BaseSerializeHandler { if (styleChanged) { if (this._nullCellCount > 0) { - this._currentRow += `\u001b[${this._nullCellCount}C` + this._currentRow += " ".repeat(this._nullCellCount) this._nullCellCount = 0 } @@ -417,7 +413,7 @@ class StringSerializeHandler extends BaseSerializeHandler { this._nullCellCount += cell.getWidth() } else { if (this._nullCellCount > 0) { - this._currentRow += `\u001b[${this._nullCellCount}C` + this._currentRow += " ".repeat(this._nullCellCount) this._nullCellCount = 0 } diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e41575e7ad4..13d9d147e25 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,5 +1,5 @@ import "@/index.css" -import { ErrorBoundary, Show, type ParentProps } from "solid-js" +import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js" import { Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { Font } from "@opencode-ai/ui/font" @@ -20,16 +20,20 @@ import { FileProvider } from "@/context/file" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" +import { Logo } from "@opencode-ai/ui/logo" import Layout from "@/pages/layout" -import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" -import Session from "@/pages/session" import { ErrorPage } from "./pages/error" import { iife } from "@opencode-ai/util/iife" +import { Suspense } from "solid-js" + +const Home = lazy(() => import("@/pages/home")) +const Session = lazy(() => import("@/pages/session")) +const Loading = () =>
Loading...
declare global { interface Window { - __OPENCODE__?: { updaterEnabled?: boolean; port?: number } + __OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean } } } @@ -45,16 +49,7 @@ const defaultServerUrl = iife(() => { return window.location.origin }) -function ServerKey(props: ParentProps) { - const server = useServer() - return ( - - {props.children} - - ) -} - -export function App() { +export function AppBaseProviders(props: ParentProps) { return ( @@ -63,48 +58,7 @@ export function App() { - - - - - - ( - - - - - {props.children} - - - - - )} - > - - - } /> - ( - - - - - - - - - - )} - /> - - - - - - - + {props.children} @@ -113,3 +67,66 @@ export function App() { ) } + +function ServerKey(props: ParentProps) { + const server = useServer() + return ( + + {props.children} + + ) +} + +export function AppInterface() { + return ( + + + + + ( + + + + + {props.children} + + + + + )} + > + ( + }> + + + )} + /> + + } /> + ( + + + + + }> + + + + + + + )} + /> + + + + + + + ) +} diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 8e68a3eb805..9e3bbeddd05 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -27,6 +27,7 @@ export function DialogSelectFile() { const value = file.tab(path) tabs().open(value) file.load(path) + layout.review.open() } dialog.close() }} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 8d917213aa3..3b14098a393 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1,5 +1,17 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" +import { + createEffect, + on, + Component, + Show, + For, + onMount, + onCleanup, + Switch, + Match, + createMemo, + createSignal, +} from "solid-js" import { createStore, produce } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" @@ -12,6 +24,7 @@ import { usePrompt, ImageAttachmentPart, AgentPart, + FileAttachmentPart, } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" @@ -29,10 +42,16 @@ import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" -import { persisted } from "@/utils/persist" +import { Persist, persisted } from "@/utils/persist" import { Identifier } from "@/utils/id" import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" +import { useGlobalSync } from "@/context/global-sync" +import { usePlatform } from "@/context/platform" +import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client" +import { Binary } from "@opencode-ai/util/binary" +import { showToast } from "@opencode-ai/ui/toast" +import { base64Encode } from "@opencode-ai/util/encode" const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] @@ -40,6 +59,8 @@ const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] interface PromptInputProps { class?: string ref?: (el: HTMLDivElement) => void + newSessionWorktree?: string + onNewSessionWorktreeReset?: () => void } const PLACEHOLDERS = [ @@ -83,6 +104,8 @@ export const PromptInput: Component = (props) => { const navigate = useNavigate() const sdk = useSDK() const sync = useSync() + const globalSync = useGlobalSync() + const platform = usePlatform() const local = useLocal() const files = useFile() const prompt = usePrompt() @@ -95,6 +118,7 @@ export const PromptInput: Component = (props) => { let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement let scrollRef!: HTMLDivElement + let slashPopoverRef!: HTMLDivElement const scrollCursorIntoView = () => { const container = scrollRef @@ -141,6 +165,9 @@ export const PromptInput: Component = (props) => { }, ) const working = createMemo(() => status()?.type !== "idle") + const imageAttachments = createMemo( + () => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[], + ) const [store, setStore] = createStore<{ popover: "at" | "slash" | null @@ -148,25 +175,21 @@ export const PromptInput: Component = (props) => { savedPrompt: Prompt | null placeholder: number dragging: boolean - imageAttachments: ImageAttachmentPart[] mode: "normal" | "shell" applyingHistory: boolean - killBuffer: string }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), dragging: false, - imageAttachments: [], mode: "normal", applyingHistory: false, - killBuffer: "", }) const MAX_HISTORY = 100 const [history, setHistory] = persisted( - "prompt-history.v1", + Persist.global("prompt-history", ["prompt-history.v1"]), createStore<{ entries: Prompt[] }>({ @@ -174,7 +197,7 @@ export const PromptInput: Component = (props) => { }), ) const [shellHistory, setShellHistory] = persisted( - "prompt-history-shell.v1", + Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), createStore<{ entries: Prompt[] }>({ @@ -236,6 +259,8 @@ export const PromptInput: Component = (props) => { }) const isFocused = createFocusSignal(() => editorRef) + const [composing, setComposing] = createSignal(false) + const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const addImageAttachment = async (file: File) => { if (!ACCEPTED_FILE_TYPES.includes(file.type)) return @@ -250,21 +275,16 @@ export const PromptInput: Component = (props) => { mime: file.type, dataUrl, } - setStore( - produce((draft) => { - draft.imageAttachments.push(attachment) - }), - ) + const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef) + prompt.set([...prompt.current(), attachment], cursorPosition) } reader.readAsDataURL(file) } const removeImageAttachment = (id: string) => { - setStore( - produce((draft) => { - draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id) - }), - ) + const current = prompt.current() + const next = current.filter((part) => part.type !== "image" || part.id !== id) + prompt.set(next, prompt.cursor()) } const handlePaste = async (event: ClipboardEvent) => { @@ -292,6 +312,8 @@ export const PromptInput: Component = (props) => { } const handleGlobalDragOver = (event: DragEvent) => { + if (dialog.active) return + event.preventDefault() const hasFiles = event.dataTransfer?.types.includes("Files") if (hasFiles) { @@ -300,6 +322,8 @@ export const PromptInput: Component = (props) => { } const handleGlobalDragLeave = (event: DragEvent) => { + if (dialog.active) return + // relatedTarget is null when leaving the document window if (!event.relatedTarget) { setStore("dragging", false) @@ -307,6 +331,8 @@ export const PromptInput: Component = (props) => { } const handleGlobalDrop = async (event: DragEvent) => { + if (dialog.active) return + event.preventDefault() setStore("dragging", false) @@ -430,6 +456,7 @@ export const PromptInput: Component = (props) => { active: slashActive, onInput: slashOnInput, onKeyDown: slashOnKeyDown, + refetch: slashRefetch, } = useFilteredList({ items: slashCommands, key: (x) => x?.id, @@ -437,32 +464,79 @@ export const PromptInput: Component = (props) => { onSelect: handleSlashSelect, }) + const createPill = (part: FileAttachmentPart | AgentPart) => { + const pill = document.createElement("span") + pill.textContent = part.content + pill.setAttribute("data-type", part.type) + if (part.type === "file") pill.setAttribute("data-path", part.path) + if (part.type === "agent") pill.setAttribute("data-name", part.name) + pill.setAttribute("contenteditable", "false") + pill.style.userSelect = "text" + pill.style.cursor = "default" + return pill + } + + const isNormalizedEditor = () => + Array.from(editorRef.childNodes).every((node) => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (!text.includes("\u200B")) return true + if (text !== "\u200B") return false + + const prev = node.previousSibling + const next = node.nextSibling + const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" + const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR" + if (!prevIsBr && !nextIsBr) return false + if (nextIsBr && !prevIsBr && prev) return false + return true + } + if (node.nodeType !== Node.ELEMENT_NODE) return false + const el = node as HTMLElement + if (el.dataset.type === "file") return true + if (el.dataset.type === "agent") return true + return el.tagName === "BR" + }) + + const renderEditor = (parts: Prompt) => { + editorRef.innerHTML = "" + for (const part of parts) { + if (part.type === "text") { + editorRef.appendChild(createTextFragment(part.content)) + continue + } + if (part.type === "file" || part.type === "agent") { + editorRef.appendChild(createPill(part)) + } + } + } + + createEffect( + on( + () => sync.data.command, + () => slashRefetch(), + { defer: true }, + ), + ) + + // Auto-scroll active command into view when navigating with keyboard + createEffect(() => { + const activeId = slashActive() + if (!activeId || !slashPopoverRef) return + + requestAnimationFrame(() => { + const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + }) + }) + createEffect( on( () => prompt.current(), (currentParts) => { + const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt const domParts = parseFromDOM() - const normalized = Array.from(editorRef.childNodes).every((node) => { - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent ?? "" - if (!text.includes("\u200B")) return true - if (text !== "\u200B") return false - - const prev = node.previousSibling - const next = node.nextSibling - const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" - const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR" - if (!prevIsBr && !nextIsBr) return false - if (nextIsBr && !prevIsBr && prev) return false - return true - } - if (node.nodeType !== Node.ELEMENT_NODE) return false - const el = node as HTMLElement - if (el.dataset.type === "file") return true - if (el.dataset.type === "agent") return true - return el.tagName === "BR" - }) - if (normalized && isPromptEqual(currentParts, domParts)) return + if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return const selection = window.getSelection() let cursorPosition: number | null = null @@ -470,30 +544,7 @@ export const PromptInput: Component = (props) => { cursorPosition = getCursorPosition(editorRef) } - editorRef.innerHTML = "" - currentParts.forEach((part) => { - if (part.type === "text") { - editorRef.appendChild(createTextFragment(part.content)) - } else if (part.type === "file") { - const pill = document.createElement("span") - pill.textContent = part.content - pill.setAttribute("data-type", "file") - pill.setAttribute("data-path", part.path) - pill.setAttribute("contenteditable", "false") - pill.style.userSelect = "text" - pill.style.cursor = "default" - editorRef.appendChild(pill) - } else if (part.type === "agent") { - const pill = document.createElement("span") - pill.textContent = part.content - pill.setAttribute("data-type", "agent") - pill.setAttribute("data-name", part.name) - pill.setAttribute("contenteditable", "false") - pill.style.userSelect = "text" - pill.style.cursor = "default" - editorRef.appendChild(pill) - } - }) + renderEditor(inputParts) if (cursorPosition !== null) { setCursorPosition(editorRef, cursorPosition) @@ -584,11 +635,12 @@ export const PromptInput: Component = (props) => { const handleInput = () => { const rawParts = parseFromDOM() + const images = imageAttachments() const cursorPosition = getCursorPosition(editorRef) const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") const trimmed = rawText.replace(/\u200B/g, "").trim() const hasNonText = rawParts.some((part) => part.type !== "text") - const shouldReset = trimmed.length === 0 && !hasNonText + const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0 if (shouldReset) { setStore("popover", null) @@ -627,7 +679,7 @@ export const PromptInput: Component = (props) => { setStore("savedPrompt", null) } - prompt.set(rawParts, cursorPosition) + prompt.set([...rawParts, ...images], cursorPosition) queueScroll() } @@ -671,40 +723,8 @@ export const PromptInput: Component = (props) => { const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) - if (part.type === "file") { - const pill = document.createElement("span") - pill.textContent = part.content - pill.setAttribute("data-type", "file") - pill.setAttribute("data-path", part.path) - pill.setAttribute("contenteditable", "false") - pill.style.userSelect = "text" - pill.style.cursor = "default" - - const gap = document.createTextNode(" ") - const range = selection.getRangeAt(0) - - if (atMatch) { - const start = atMatch.index ?? cursorPosition - atMatch[0].length - setRangeEdge(range, "start", start) - setRangeEdge(range, "end", cursorPosition) - } - - range.deleteContents() - range.insertNode(gap) - range.insertNode(pill) - range.setStartAfter(gap) - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } else if (part.type === "agent") { - const pill = document.createElement("span") - pill.textContent = part.content - pill.setAttribute("data-type", "agent") - pill.setAttribute("data-name", part.name) - pill.setAttribute("contenteditable", "false") - pill.style.userSelect = "text" - pill.style.cursor = "default" - + if (part.type === "file" || part.type === "agent") { + const pill = createPill(part) const gap = document.createTextNode(" ") const range = selection.getRangeAt(0) @@ -750,77 +770,6 @@ export const PromptInput: Component = (props) => { setStore("popover", null) } - const setSelectionOffsets = (start: number, end: number) => { - const selection = window.getSelection() - if (!selection) return false - - const length = promptLength(prompt.current()) - const a = Math.max(0, Math.min(start, length)) - const b = Math.max(0, Math.min(end, length)) - const rangeStart = Math.min(a, b) - const rangeEnd = Math.max(a, b) - - const range = document.createRange() - range.selectNodeContents(editorRef) - - const setEdge = (edge: "start" | "end", offset: number) => { - let remaining = offset - const nodes = Array.from(editorRef.childNodes) - - for (const node of nodes) { - const length = getNodeLength(node) - const isText = node.nodeType === Node.TEXT_NODE - const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" - const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" - - if (isText && remaining <= length) { - if (edge === "start") range.setStart(node, remaining) - if (edge === "end") range.setEnd(node, remaining) - return - } - - if ((isFile || isBreak) && remaining <= length) { - if (edge === "start" && remaining === 0) range.setStartBefore(node) - if (edge === "start" && remaining > 0) range.setStartAfter(node) - if (edge === "end" && remaining === 0) range.setEndBefore(node) - if (edge === "end" && remaining > 0) range.setEndAfter(node) - return - } - - remaining -= length - } - - const last = editorRef.lastChild - if (!last) { - if (edge === "start") range.setStart(editorRef, 0) - if (edge === "end") range.setEnd(editorRef, 0) - return - } - if (edge === "start") range.setStartAfter(last) - if (edge === "end") range.setEndAfter(last) - } - - setEdge("start", rangeStart) - setEdge("end", rangeEnd) - selection.removeAllRanges() - selection.addRange(range) - return true - } - - const replaceOffsets = (start: number, end: number, content: string) => { - if (!setSelectionOffsets(start, end)) return false - addPart({ type: "text", content, start: 0, end: 0 }) - return true - } - - const killText = (start: number, end: number) => { - if (start === end) return - const current = prompt.current() - if (!current.every((part) => part.type === "text")) return - const text = current.map((part) => part.content).join("") - setStore("killBuffer", text.slice(start, end)) - } - const abort = () => sdk.client.session .abort({ @@ -833,16 +782,14 @@ export const PromptInput: Component = (props) => { .map((p) => ("content" in p ? p.content : "")) .join("") .trim() - if (!text) return + const hasImages = prompt.some((part) => part.type === "image") + if (!text && !hasImages) return const entry = clonePromptParts(prompt) const currentHistory = mode === "shell" ? shellHistory : history const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory const lastEntry = currentHistory.entries[0] - if (lastEntry) { - const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("") - if (lastText === text) return - } + if (lastEntry && isPromptEqual(lastEntry, entry)) return setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY)) } @@ -931,6 +878,10 @@ export const PromptInput: Component = (props) => { } } + if (event.key === "Enter" && isImeComposing(event)) { + return + } + if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { if (store.popover === "at") { atOnKeyDown(event) @@ -942,7 +893,6 @@ export const PromptInput: Component = (props) => { } const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey - const alt = event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey if (ctrl && event.code === "KeyG") { if (store.popover) { @@ -957,148 +907,6 @@ export const PromptInput: Component = (props) => { return } - if (ctrl || alt) { - const { collapsed, cursorPosition, textLength } = getCaretState() - if (collapsed) { - const current = prompt.current() - const text = current.map((part) => ("content" in part ? part.content : "")).join("") - - if (ctrl) { - if (event.code === "KeyA") { - const pos = text.lastIndexOf("\n", cursorPosition - 1) + 1 - setCursorPosition(editorRef, pos) - event.preventDefault() - queueScroll() - return - } - - if (event.code === "KeyE") { - const next = text.indexOf("\n", cursorPosition) - const pos = next === -1 ? textLength : next - setCursorPosition(editorRef, pos) - event.preventDefault() - queueScroll() - return - } - - if (event.code === "KeyB") { - const pos = Math.max(0, cursorPosition - 1) - setCursorPosition(editorRef, pos) - event.preventDefault() - queueScroll() - return - } - - if (event.code === "KeyF") { - const pos = Math.min(textLength, cursorPosition + 1) - setCursorPosition(editorRef, pos) - event.preventDefault() - queueScroll() - return - } - - if (event.code === "KeyD") { - if (store.mode === "shell" && cursorPosition === 0 && textLength === 0) { - setStore("mode", "normal") - event.preventDefault() - return - } - if (cursorPosition >= textLength) return - replaceOffsets(cursorPosition, cursorPosition + 1, "") - event.preventDefault() - return - } - - if (event.code === "KeyK") { - const next = text.indexOf("\n", cursorPosition) - const lineEnd = next === -1 ? textLength : next - const end = lineEnd === cursorPosition && lineEnd < textLength ? lineEnd + 1 : lineEnd - if (end === cursorPosition) return - killText(cursorPosition, end) - replaceOffsets(cursorPosition, end, "") - event.preventDefault() - return - } - - if (event.code === "KeyU") { - const start = text.lastIndexOf("\n", cursorPosition - 1) + 1 - if (start === cursorPosition) return - killText(start, cursorPosition) - replaceOffsets(start, cursorPosition, "") - event.preventDefault() - return - } - - if (event.code === "KeyW") { - let start = cursorPosition - while (start > 0 && /\s/.test(text[start - 1])) start -= 1 - while (start > 0 && !/\s/.test(text[start - 1])) start -= 1 - if (start === cursorPosition) return - killText(start, cursorPosition) - replaceOffsets(start, cursorPosition, "") - event.preventDefault() - return - } - - if (event.code === "KeyY") { - if (!store.killBuffer) return - addPart({ type: "text", content: store.killBuffer, start: 0, end: 0 }) - event.preventDefault() - return - } - - if (event.code === "KeyT") { - if (!current.every((part) => part.type === "text")) return - if (textLength < 2) return - if (cursorPosition === 0) return - - const atEnd = cursorPosition === textLength - const first = atEnd ? cursorPosition - 2 : cursorPosition - 1 - const second = atEnd ? cursorPosition - 1 : cursorPosition - - if (text[first] === "\n" || text[second] === "\n") return - - replaceOffsets(first, second + 1, `${text[second]}${text[first]}`) - event.preventDefault() - return - } - } - - if (alt) { - if (event.code === "KeyB") { - let pos = cursorPosition - while (pos > 0 && /\s/.test(text[pos - 1])) pos -= 1 - while (pos > 0 && !/\s/.test(text[pos - 1])) pos -= 1 - setCursorPosition(editorRef, pos) - event.preventDefault() - queueScroll() - return - } - - if (event.code === "KeyF") { - let pos = cursorPosition - while (pos < textLength && /\s/.test(text[pos])) pos += 1 - while (pos < textLength && !/\s/.test(text[pos])) pos += 1 - setCursorPosition(editorRef, pos) - event.preventDefault() - queueScroll() - return - } - - if (event.code === "KeyD") { - let end = cursorPosition - while (end < textLength && /\s/.test(text[end])) end += 1 - while (end < textLength && !/\s/.test(text[end])) end += 1 - if (end === cursorPosition) return - killText(cursorPosition, end) - replaceOffsets(cursorPosition, end, "") - event.preventDefault() - return - } - } - } - } - if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (event.altKey || event.ctrlKey || event.metaKey) return const { collapsed } = getCaretState() @@ -1152,30 +960,174 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() + const currentPrompt = prompt.current() const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") - const hasImageAttachments = store.imageAttachments.length > 0 - if (text.trim().length === 0 && !hasImageAttachments) { + const images = imageAttachments().slice() + const mode = store.mode + + if (text.trim().length === 0 && images.length === 0) { if (working()) abort() return } - addToHistory(currentPrompt, store.mode) + const currentModel = local.model.current() + const currentAgent = local.agent.current() + if (!currentModel || !currentAgent) { + showToast({ + title: "Select an agent and model", + description: "Choose an agent and model before sending a prompt.", + }) + return + } + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return "Request failed" + } + + addToHistory(currentPrompt, mode) setStore("historyIndex", -1) setStore("savedPrompt", null) - let existing = info() - if (!existing) { - const created = await sdk.client.session.create() - existing = created.data ?? undefined - if (existing) navigate(existing.id) + const projectDirectory = sdk.directory + const isNewSession = !params.id + const worktreeSelection = props.newSessionWorktree ?? "main" + + let sessionDirectory = projectDirectory + let client = sdk.client + + if (isNewSession) { + if (worktreeSelection === "create") { + const createdWorktree = await client.worktree + .create({ directory: projectDirectory }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: "Failed to create worktree", + description: errorMessage(err), + }) + return undefined + }) + + if (!createdWorktree?.directory) { + showToast({ + title: "Failed to create worktree", + description: "Request failed", + }) + return + } + sessionDirectory = createdWorktree.directory + } + + if (worktreeSelection !== "main" && worktreeSelection !== "create") { + sessionDirectory = worktreeSelection + } + + if (sessionDirectory !== projectDirectory) { + client = createOpencodeClient({ + baseUrl: sdk.url, + fetch: platform.fetch, + directory: sessionDirectory, + throwOnError: true, + }) + globalSync.child(sessionDirectory) + } + + props.onNewSessionWorktreeReset?.() + } + + let session = info() + if (!session && isNewSession) { + session = await client.session.create().then((x) => x.data ?? undefined) + if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + } + if (!session) return + + const model = { + modelID: currentModel.id, + providerID: currentModel.provider.id, + } + const agent = currentAgent.name + const variant = local.model.variant.current() + + const clearInput = () => { + prompt.reset() + setStore("mode", "normal") + setStore("popover", null) + } + + const restoreInput = () => { + prompt.set(currentPrompt, promptLength(currentPrompt)) + setStore("mode", mode) + setStore("popover", null) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, promptLength(currentPrompt)) + queueScroll() + }) + } + + if (mode === "shell") { + clearInput() + client.session + .shell({ + sessionID: session.id, + agent, + model, + command: text, + }) + .catch((err) => { + showToast({ + title: "Failed to send shell command", + description: errorMessage(err), + }) + restoreInput() + }) + return } - if (!existing) return - const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) - const fileAttachments = currentPrompt.filter( - (part) => part.type === "file", - ) as import("@/context/prompt").FileAttachmentPart[] + if (text.startsWith("/")) { + const [cmdName, ...args] = text.split(" ") + const commandName = cmdName.slice(1) + const customCommand = sync.data.command.find((c) => c.name === commandName) + if (customCommand) { + clearInput() + client.session + .command({ + sessionID: session.id, + command: commandName, + arguments: args.join(" "), + agent, + model: `${model.providerID}/${model.modelID}`, + variant, + parts: images.map((attachment) => ({ + id: Identifier.ascending("part"), + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })), + }) + .catch((err) => { + showToast({ + title: "Failed to send command", + description: errorMessage(err), + }) + restoreInput() + }) + return + } + } + + const toAbsolutePath = (path: string) => + path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/") + + const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[] const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[] const fileAttachmentParts = fileAttachments.map((attachment) => { @@ -1247,7 +1199,7 @@ export const PromptInput: Component = (props) => { addContextFile(item.path, item.selection) } - const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ + const imageAttachmentParts = images.map((attachment) => ({ id: Identifier.ascending("part"), type: "file" as const, mime: attachment.mime, @@ -1255,60 +1207,6 @@ export const PromptInput: Component = (props) => { filename: attachment.filename, })) - const isShellMode = store.mode === "shell" - editorRef.innerHTML = "" - prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) - setStore("imageAttachments", []) - setStore("mode", "normal") - - const currentModel = local.model.current() - const currentAgent = local.agent.current() - if (!currentModel || !currentAgent) { - console.warn("No agent or model available for prompt submission") - return - } - const model = { - modelID: currentModel.id, - providerID: currentModel.provider.id, - } - const agent = currentAgent.name - const variant = local.model.variant.current() - - if (isShellMode) { - sdk.client.session - .shell({ - sessionID: existing.id, - agent, - model, - command: text, - }) - .catch((e) => { - console.error("Failed to send shell command", e) - }) - return - } - - if (text.startsWith("/")) { - const [cmdName, ...args] = text.split(" ") - const commandName = cmdName.slice(1) - const customCommand = sync.data.command.find((c) => c.name === commandName) - if (customCommand) { - sdk.client.session - .command({ - sessionID: existing.id, - command: commandName, - arguments: args.join(" "), - agent, - model: `${model.providerID}/${model.modelID}`, - variant, - }) - .catch((e) => { - console.error("Failed to send command", e) - }) - return - } - } - const messageID = Identifier.ascending("message") const textPart = { id: Identifier.ascending("part"), @@ -1322,31 +1220,74 @@ export const PromptInput: Component = (props) => { ...agentAttachmentParts, ...imageAttachmentParts, ] + const optimisticParts = requestParts.map((part) => ({ ...part, - sessionID: existing.id, + sessionID: session.id, messageID, - })) + })) as unknown as Part[] - sync.session.addOptimisticMessage({ - sessionID: existing.id, - messageID, - parts: optimisticParts, + const optimisticMessage: Message = { + id: messageID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, agent, model, - }) + } - sdk.client.session + const setSyncStore = sessionDirectory === projectDirectory ? sync.set : globalSync.child(sessionDirectory)[1] + + const addOptimisticMessage = () => { + setSyncStore( + produce((draft) => { + const messages = draft.message[session.id] + if (!messages) { + draft.message[session.id] = [optimisticMessage] + } else { + const result = Binary.search(messages, messageID, (m) => m.id) + messages.splice(result.index, 0, optimisticMessage) + } + draft.part[messageID] = optimisticParts + .filter((p) => !!p?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + }), + ) + } + + const removeOptimisticMessage = () => { + setSyncStore( + produce((draft) => { + const messages = draft.message[session.id] + if (messages) { + const result = Binary.search(messages, messageID, (m) => m.id) + if (result.found) messages.splice(result.index, 1) + } + delete draft.part[messageID] + }), + ) + } + + clearInput() + addOptimisticMessage() + + client.session .prompt({ - sessionID: existing.id, + sessionID: session.id, agent, model, messageID, parts: requestParts, variant, }) - .catch((e) => { - console.error("Failed to send prompt", e) + .catch((err) => { + showToast({ + title: "Failed to send prompt", + description: errorMessage(err), + }) + removeOptimisticMessage() + restoreInput() }) } @@ -1354,6 +1295,9 @@ export const PromptInput: Component = (props) => {
{ + if (store.popover === "slash") slashPopoverRef = el + }} class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 overflow-auto no-scrollbar flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" @@ -1412,6 +1356,7 @@ export const PromptInput: Component = (props) => { {(cmd) => (
- 0}> + 0}>
- + {(attachment) => (
= (props) => { }} contenteditable="true" onInput={handleInput} + onCompositionStart={() => setComposing(true)} + onCompositionEnd={() => setComposing(false)} onKeyDown={handleKeyDown} classList={{ "select-text": true, @@ -1572,7 +1519,7 @@ export const PromptInput: Component = (props) => { "font-mono!": store.mode === "shell", }} /> - +
{store.mode === "shell" ? "Enter shell command..." @@ -1646,14 +1593,14 @@ export const PromptInput: Component = (props) => { onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} classList={{ "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, - "text-text-base": !permission.isAutoAccepting(params.id!), - "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!), + "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory), + "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory), }} > @@ -1665,7 +1612,7 @@ export const PromptInput: Component = (props) => { { const file = e.currentTarget.files?.[0] @@ -1676,7 +1623,7 @@ export const PromptInput: Component = (props) => {
- + @@ -1705,7 +1652,7 @@ export const PromptInput: Component = (props) => { > { + const restoreScroll = (retries = 0) => { const el = scroll if (!el) return const s = props.view()?.scroll("context") if (!s) return + // Wait for content to be scrollable - content may not have rendered yet + if (el.scrollHeight <= el.clientHeight && retries < 10) { + requestAnimationFrame(() => restoreScroll(retries + 1)) + return + } + if (el.scrollTop !== s.y) el.scrollTop = s.y if (el.scrollLeft !== s.x) el.scrollLeft = s.x } diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 0a0b536075a..4958ad2c353 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { iife } from "@opencode-ai/util/iife" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -20,6 +20,7 @@ import { DialogSelectServer } from "@/components/dialog-select-server" import { SessionLspIndicator } from "@/components/session-lsp-indicator" import { SessionMcpIndicator } from "@/components/session-mcp-indicator" import type { Session } from "@opencode-ai/sdk/v2/client" +import { same } from "@/utils/same" export function SessionHeader() { const globalSDK = useGlobalSDK() @@ -31,10 +32,17 @@ export function SessionHeader() { const dialog = useDialog() const sync = useSync() + const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) + const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID)) - const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) + const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id)) + const parentSession = createMemo(() => { + const current = currentSession() + if (!current?.parentID) return undefined + return sync.data.session.find((s) => s.id === current.parentID) + }) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") - const branch = createMemo(() => sync.data.vcs?.branch) + const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same }) function navigateToProject(directory: string) { navigate(`/${base64Encode(directory)}`) @@ -42,11 +50,13 @@ export function SessionHeader() { function navigateToSession(session: Session | undefined) { if (!session) return + // Only navigate if we're actually changing to a different session + if (session.id === params.id) return navigate(`/${params.dir}/session/${session.id}`) } return ( -
+
@@ -807,7 +827,12 @@ export default function Layout(props: ParentProps) {