diff --git a/.github/last-synced-tag b/.github/last-synced-tag index 3fe93612e6f..7f9004ba0b8 100644 --- a/.github/last-synced-tag +++ b/.github/last-synced-tag @@ -1 +1 @@ -v1.1.25 +v1.1.26 diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 1d9d1375113..83bae633738 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -20,10 +20,10 @@ permissions: jobs: publish: runs-on: blacksmith-4vcpu-ubuntu-2404 - # Only run if tests passed (workflow_run) or manual dispatch + # Only run if tests passed (workflow_run) or manual dispatch ON INTEGRATION BRANCH # Also skip release commits to prevent infinite loop if: | - (github.event_name == 'workflow_dispatch') || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/integration') || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && !startsWith(github.event.workflow_run.head_commit.message, 'release:') && diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 440f988a300..244ea80cd66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,54 @@ jobs: - name: Setup Bun uses: ./.github/actions/setup-bun + - name: Install Playwright browsers + working-directory: packages/app + run: bunx playwright install --with-deps + + - name: Seed opencode data + working-directory: packages/opencode + run: bun script/seed-e2e.ts + env: + MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + OPENCODE_DISABLE_MODELS_FETCH: "true" + OPENCODE_DISABLE_SHARE: "true" + OPENCODE_DISABLE_LSP_DOWNLOAD: "true" + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" + OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home + XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share + XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache + XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config + XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state + OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }} + OPENCODE_E2E_SESSION_TITLE: "E2E Session" + OPENCODE_E2E_MESSAGE: "Seeded for UI e2e" + OPENCODE_E2E_MODEL: "opencode/gpt-5-nano" + + - name: Run opencode server + run: bun run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 & + env: + MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + OPENCODE_DISABLE_MODELS_FETCH: "true" + OPENCODE_DISABLE_SHARE: "true" + OPENCODE_DISABLE_LSP_DOWNLOAD: "true" + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" + OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home + XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share + XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache + XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config + XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state + OPENCODE_CLIENT: "app" + + - name: Wait for opencode server + run: | + for i in {1..60}; do + curl -fsS "http://localhost:4096/global/health" > /dev/null && exit 0 + sleep 1 + done + exit 1 + - name: run run: | git config --global user.email "bot@opencode.ai" @@ -27,3 +75,20 @@ jobs: bun turbo test env: CI: true + MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + OPENCODE_DISABLE_MODELS_FETCH: "true" + OPENCODE_DISABLE_SHARE: "true" + OPENCODE_DISABLE_LSP_DOWNLOAD: "true" + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" + OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home + XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share + XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache + XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config + XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state + PLAYWRIGHT_SERVER_HOST: "localhost" + PLAYWRIGHT_SERVER_PORT: "4096" + VITE_OPENCODE_SERVER_HOST: "localhost" + VITE_OPENCODE_SERVER_PORT: "4096" + OPENCODE_CLIENT: "app" + timeout-minutes: 30 diff --git a/.gitignore b/.gitignore index c8a8665afdd..78a77f81982 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .DS_Store node_modules -.opencode .worktrees .sst .env @@ -21,11 +20,9 @@ opencode.json a.out target .scripts -docker/workspace .direnv/ # Local dev files opencode-dev logs/ -.loop* *.bun-build diff --git a/README.md b/README.md index 85606261f4d..64ca1ef7a6f 100644 --- a/README.md +++ b/README.md @@ -1,230 +1,76 @@ -

shuvcode -

-

A fork of opencode - The AI coding agent built for the terminal.

- npm - GitHub release + + + + + OpenCode logo + +

- - ---- - -## Screenshots - -### Desktop App - -

- Desktop session with diff viewer -

- -*Desktop session view with AI chat, session sidebar, and real-time code diff review* - -### Mobile PWA - +

The open source AI coding agent.

- Mobile recent projects - Mobile sidebar menu - Mobile AI terminal view + Discord + npm + Build status

-

- Mobile commit summary - Mobile git clone dialog - Mobile theme selector -

- -*Mobile PWA: Recent projects, sidebar menu, AI chat with terminal, commit summary, git clone dialog, and theme selector* +[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) --- -## Installation +### Installation ```bash -# curl install -curl -fsSL https://shuv.ai/install | bash - -# npm -npm i -g shuvcode@latest # or bun/pnpm/yarn +# YOLO +curl -fsSL https://opencode.ai/install | bash + +# Package managers +npm i -g opencode-ai@latest # or bun/pnpm/yarn +scoop install opencode # Windows +choco install opencode # Windows +brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date) +brew install opencode # macOS and Linux (official brew formula, updated less) +paru -S opencode-bin # Arch Linux +mise use -g opencode # Any OS +nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch ``` ---- - -## About - -This fork serves as an integration testing ground for upstream PRs before they are merged into the main opencode repository. We merge, test, and validate promising features and fixes to help ensure quality contributions to the upstream project. - -The desktop app is available from the [releases page](https://github.com/Latitudes-Dev/shuvcode/releases). - ---- - -## Merged PRs (Pending Upstream) - -The following PRs have been merged into this fork and are awaiting merge into upstream: - -| PR | Title | Author | Status | Description | -| ----------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------ | ------ | ------------------------------------------------------------------------ | -| [#6476](https://github.com/sst/opencode/pull/6476) | Edit suggested changes before applying | [@dmmulroy](https://github.com/dmmulroy) | Open | Press 'e' to edit AI suggestions in your editor before accepting | -| [#6507](https://github.com/sst/opencode/pull/6507) | Optimize Ripgrep.tree() (109x faster) | [@Karavil](https://github.com/Karavil) | Open | 109x performance improvement for large repos by streaming ripgrep output | -| [#5432](https://github.com/sst/opencode/pull/5432) | Stream grep output to prevent OOM | [@Hona](https://github.com/Hona) | Open | Stream ripgrep output in grep tool to prevent memory exhaustion | -| [#6360](https://github.com/sst/opencode/pull/6360) | Desktop: Edit Project | [@dbpolito](https://github.com/dbpolito) | Merged | Edit project name, icon color, and custom icon image in desktop sidebar | -| [#6368](https://github.com/sst/opencode/pull/6368) | Desktop: Sidebar subsessions support | [@dbpolito](https://github.com/dbpolito) | Open | Expand/collapse subsessions in sidebar with chevron indicators | -| [#6372](https://github.com/sst/opencode/pull/6372) | Desktop: Image Preview and Dedupe | [@dbpolito](https://github.com/dbpolito) | Merged | Click user attachments to preview images, dedupe file uploads | -| [#4898](https://github.com/sst/opencode/pull/4898) | Search in messages | [@OpeOginni](https://github.com/OpeOginni) | Open | Ctrl+ / to search through session messages with highlighting | -| [#4791](https://github.com/sst/opencode/pull/4791) | Bash output with ANSI | [@remorses](https://github.com/remorses) | Open | Full terminal emulation for bash output with color support | -| [#4900](https://github.com/sst/opencode/pull/4900) | Double Ctrl+C to exit | [@AmineGuitouni](https://github.com/AmineGuitouni) | Open | Require double Ctrl+C within 2 seconds to prevent accidental exits | -| [#4709](https://github.com/sst/opencode/pull/4709) | Live token usage during streaming | [@arsham](https://github.com/arsham) | Open | Real-time token tracking and display during model responses | -| [#4865](https://github.com/sst/opencode/pull/4865) | Subagents sidebar with clickable navigation | [@franlol](https://github.com/franlol) | Open | Show subagents in sidebar with click-to-navigate and parent keybind | -| [#4515](https://github.com/sst/opencode/pull/4515) | Show plugins in /status | [@spoons-and-mirrors](https://github.com/spoons-and-mirrors) | Merged | Display configured plugins in /status dialog alongside MCP/LSP servers | -| [#4411](https://github.com/sst/opencode/pull/4411) | Plugin Commands | [@spoons-and-mirrors](https://github.com/spoons-and-mirrors) | Open | Register custom `/commands` from plugins with aliases and sessionOnly | -| [#5958](https://github.com/sst/opencode/pull/5958) | AskQuestion Tool | [@iljod](https://github.com/iljod) | Open | Interactive tool for AI to collect user input via TUI/web wizard dialogs | -| [#5508](https://github.com/sst/opencode/pull/5508) | Cache management command | [@JosXa](https://github.com/JosXa) | Open | `opencode cache info` and `opencode cache clean` for plugin cache mgmt | -| [#5873](https://github.com/sst/opencode/pull/5873) | IDE integration UX improvements | [@tofunori](https://github.com/tofunori) | Open | Selection in footer, synthetic context, home screen IDE status | -| [#5917](https://github.com/sst/opencode/pull/5917) | Draggable sidebar resize | [@agustif](https://github.com/agustif) | Open | Click and drag the sidebar border to resize, width persisted to KV store | -| [#5968](https://github.com/sst/opencode/pull/5968) | Better styling for small screens | [@rekram1-node](https://github.com/rekram1-node) | Reverted | Responsive TUI layout hiding elements on short/narrow terminals | -| [#140](https://github.com/Latitudes-Dev/shuvcode/pull/140) | Toggle transparent background | [@JosXa](https://github.com/JosXa) | Open | Command palette toggle for transparent TUI background on any theme | - -_Last updated: 2026-01-07_ - -**Note:** Granular File Permissions (ariane-emory) was removed in v1.1.1 integration - upstream now provides similar functionality via PermissionNext. - ---- - -## Feature Highlights - -### Custom Server URL Settings - -Configure a custom API server URL for the desktop app: - -- **Settings dialog**: Access via command palette (Cmd/Ctrl+K → Settings) -- **URL validation**: Real-time validation with connection testing -- **Persistence**: Saved to localStorage, survives browser refresh -- **Error recovery**: Configure server URL directly from connection error pages - -Useful for self-hosted deployments or development environments. - ---- - -### GitHub App Integration - -The fork includes a dedicated GitHub App (`shuvcode-agent`) for GitHub Actions automation: - -- **Automatic PR reviews**: Trigger with `/shuvcode` or `/shuv` comments -- **Token exchange**: Secure OIDC-based authentication for CI workflows -- **Installation**: Run `shuvcode github install` to add the app to your repos - -The API is deployed to `api.shuv.ai` with Cloudflare Durable Objects for session sync. - ---- - -### Enhanced Create Project Dialog - -The "Add Project" dialog now has three tabs: +> [!TIP] +> Remove versions older than 0.1.x before installing. -- **Add Existing**: Browse and search folders from $HOME with fuzzy search -- **Create New**: Directory picker + project name field with path validation -- **Git Clone**: Clone from URL (coming soon) +### Desktop App (BETA) -Features git repo detection, existing project badges, and keyboard navigation. +OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/anomalyco/opencode/releases) or [opencode.ai/download](https://opencode.ai/download). ---- - -### Desktop PWA Mobile Support - -The desktop web app now fully supports mobile devices as a Progressive Web App (PWA): - -- **Dynamic island handling**: Proper background color fills the notch/dynamic island area on newer iPhones -- **Mobile menu**: Full-screen navigation overlay accessible via hamburger button -- **Review overlay**: Access session changes and file viewer on mobile via the "Review" button in the header -- **Split/inline diff toggle**: Switch between side-by-side and inline diff views in the review panel -- **Responsive layout**: Timeline rail hidden on mobile, session pane takes full width - -Install as PWA on iOS: Open in Safari → Share → Add to Home Screen - ---- - -### IDE Integration (Cursor/VSCode) - -Connect to Cursor, VSCode, or other supported IDEs for enhanced workflow: - -- **Live text selection** from your editor is displayed in the TUI footer -- **Selection context** is automatically included in prompts (invisible to you, but sent to the model) -- **IDE status** shown on the home screen footer -- **Diff view** support for file edits (open diffs directly in your IDE) - -Configure in `opencode.json`: +| Platform | 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`, or AppImage | -```jsonc -{ - "ide": { - "lockfile_dir": "~/.cursor/opencode/", - "auth_header_name": "x-opencode-auth", - }, -} +```bash +# macOS (Homebrew) +brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop ``` -Supported IDEs: Cursor, VSCode, VSCode Insiders, VSCodium, Windsurf - ---- - -### Add Existing Project Dialog - -The desktop "Create project" button now opens an improved "Add Project" dialog with two tabs: - -- **Add Existing**: Browse and search folders from your home directory with fuzzy search, see git repo indicators, and add existing projects with one click -- **Create New**: Original path input for creating new project directories +#### Installation Directory -The folder browser scans up to 2 levels deep from `$HOME`, prioritizes git repositories, and shows which folders are already added as projects. +The install script respects the following priority order for the installation path: ---- - -### Desktop Image Preview - -The desktop file viewer now displays actual image previews for PNG, JPG, GIF, and WEBP files instead of showing raw base64 text. Images are centered and scaled to fit within the viewport with scrolling support for large images. SVG files are excluded from image preview and render as syntax-highlighted XML code. - ---- +1. `$OPENCODE_INSTALL_DIR` - Custom installation directory +2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path +3. `$HOME/bin` - Standard user binary directory (if exists or can be created) +4. `$HOME/.opencode/bin` - Default fallback -### TUI Spinner Styles - -Choose from 60+ animated spinner styles for tool execution indicators. Access via the command palette with `Change spinner style`. Your selection is persisted across sessions. - -Available styles include braille patterns, block animations, geometric shapes, and creative concepts like moon phases, clock sweeps, and bouncing balls. - -You can also adjust the animation speed via `Change spinner speed` in the command palette. Options range from 20ms (fastest) to 500ms (slowest), with 60ms as the default. - ---- - -### TUI Layout Density - -The TUI automatically adapts its vertical spacing for small terminals (< 28 rows). Configure via `tui.density`: - -- `auto` (default): Switches to compact mode on small terminals -- `comfortable`: Standard spacing with footer and hints -- `compact`: Reduced padding, hides footer and secondary hints - -Toggle density from the command palette or set in config: - -```jsonc -{ - "tui": { - "density": "auto", - }, -} +```bash +# Examples +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 ``` ---- - -### AskQuestion Tool - -The AI can pause and ask structured questions via a wizard UI. Available in both TUI and web app. This tool is enabled by default. - -Features: -- Wizard-style multi-question dialogs with single/multi-select options -- Custom text input for freeform responses -- Keyboard navigation (1-8 quick select, Tab between questions, Enter to confirm) -- Works across TUI and web app with session resume support - ---- - ### Agents OpenCode includes two built-in agents you can switch between with the `Tab` key. @@ -240,25 +86,6 @@ This is used internally and can be invoked using `@general` in messages. Learn more about [agents](https://opencode.ai/docs/agents). -### Sessions Sidebar - -A NERDTree-style sidebar for managing sessions. Toggle with `ctrl+n`. - -| Key | Action | -| -------------- | ---------------------------- | -| `j/k` or `↑/↓` | Move cursor | -| `Enter` or `o` | Open session / Toggle expand | -| `O` | Expand all children | -| `x` | Collapse parent | -| `X` | Collapse all | -| `p` | Go to parent | -| `g/G` | Jump to top/bottom | -| `n` | New session | -| `r` | Rename session | -| `d` | Delete session | -| `?` | Show help | -| `q` or `Esc` | Close sidebar | - ### Documentation For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs). diff --git a/README.zh-CN.md b/README.zh-CN.md index 30757f5fe9d..4b56e0fb0b0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,7 +26,7 @@ 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 +scoop install opencode # Windows choco install opencode # Windows brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新) brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低) @@ -52,6 +52,8 @@ OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](htt ```bash # macOS (Homebrew Cask) brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop ``` #### 安装目录 diff --git a/README.zh-TW.md b/README.zh-TW.md index 9e27c48f27e..66664a70305 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -26,7 +26,7 @@ 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 +scoop install opencode # Windows choco install opencode # Windows brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新) brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低) @@ -52,6 +52,8 @@ OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (rele ```bash # macOS (Homebrew Cask) brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop ``` #### 安裝目錄 diff --git a/STATS.md b/STATS.md index 9a665612b14..88046dd8f34 100644 --- a/STATS.md +++ b/STATS.md @@ -203,3 +203,5 @@ | 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) | | 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) | | 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) | +| 2026-01-18 | 4,627,623 (+238,065) | 1,839,171 (+33,856) | 6,466,794 (+271,921) | +| 2026-01-19 | 4,861,108 (+233,485) | 1,863,112 (+23,941) | 6,724,220 (+257,426) | diff --git a/bun.lock b/bun.lock index ab00c1751b8..a03742bcfec 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -56,6 +56,7 @@ }, "devDependencies": { "@happy-dom/global-registrator": "20.0.11", + "@playwright/test": "1.57.0", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", @@ -71,7 +72,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -82,7 +83,7 @@ "@opencode-ai/console-mail": "workspace:*", "@opencode-ai/console-resource": "workspace:*", "@opencode-ai/ui": "workspace:*", - "@smithy/eventstream-codec": "4.2.8", + "@smithy/eventstream-codec": "4.2.7", "@smithy/util-utf8": "4.2.0", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", @@ -105,7 +106,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -132,7 +133,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -156,7 +157,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -180,7 +181,7 @@ }, "packages/desktop": { "name": "@shuvcode/desktop", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -209,7 +210,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -238,7 +239,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -254,7 +255,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.25-1", + "version": "1.1.26", "bin": { "opencode": "./bin/opencode", }, @@ -282,7 +283,7 @@ "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.1.1", + "@gitlab/gitlab-ai-provider": "3.1.2", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -359,7 +360,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -379,7 +380,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.25-1", + "version": "1.1.26", "devDependencies": { "@hey-api/openapi-ts": "0.90.4", "@tsconfig/node22": "catalog:", @@ -390,7 +391,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -403,7 +404,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -444,7 +445,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "zod": "catalog:", }, @@ -455,7 +456,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -505,6 +506,7 @@ "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", "@pierre/diffs": "1.0.2", + "@playwright/test": "1.51.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", @@ -1052,7 +1054,7 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], - "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-7AtFrCflq2NzC99bj7YaqbQDCZyaScM1+L4ujllV5syiRTFE239Uhnd/yEkPXa7sUAnNRfN3CWusCkQ2zK/q9g=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-p0NZhZJSavWDX9r/Px/mOK2YIC803GZa8iRzcg3f1C6S0qfea/HBTe4/NWvT2+2kWIwhCePGuI4FN2UFiUWXUg=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], @@ -1488,6 +1490,8 @@ "@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="], + "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + "@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], @@ -1668,7 +1672,7 @@ "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ=="], - "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="], + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="], "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.5", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-HohfmCQZjppVnKX2PnXlf47CW3j92Ki6T/vkAT2DhBR47e89pen3s4fIa7otGTtrVxmj7q+IhH0RnC5kpR8wtw=="], @@ -3482,6 +3486,10 @@ "planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="], + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -4432,14 +4440,8 @@ "@babel/plugin-transform-modules-amd/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - "@babel/plugin-transform-modules-systemjs/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - "@babel/plugin-transform-modules-systemjs/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - "@babel/plugin-transform-modules-systemjs/@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], - - "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - "@babel/plugin-transform-modules-umd/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], "@babel/plugin-transform-named-capturing-groups-regex/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], @@ -4458,6 +4460,8 @@ "@babel/plugin-transform-object-super/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + "@babel/plugin-transform-object-super/@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.27.1", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.27.1", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA=="], + "@babel/plugin-transform-optional-catch-binding/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], "@babel/plugin-transform-optional-chaining/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], @@ -4704,7 +4708,7 @@ "@slack/web-api/p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], - "@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], + "@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.11.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA=="], "@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Ogt4Zi9hEbIP17oQMd68qYOHUzmH47UkK7q7Gl55iIm9oKt27MUGrC5JfpMroeHjdkOliOA4Qt3NQ1xMq/nrlA=="], @@ -4902,6 +4906,8 @@ "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], @@ -5168,22 +5174,6 @@ "@babel/plugin-transform-function-name/@babel/traverse/@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], - "@babel/plugin-transform-modules-systemjs/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], - - "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], - - "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], - - "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], - - "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], - "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -5776,20 +5766,6 @@ "@babel/plugin-transform-function-name/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@babel/plugin-transform-modules-systemjs/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], - - "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], - - "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], - - "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], - - "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], - - "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], - "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@babel/plugin-transform-private-methods/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], diff --git a/flake.lock b/flake.lock index 2bfad510e7b..87f95fb3eb7 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1768456270, - "narHash": "sha256-NgaL2CCiUR6nsqUIY4yxkzz07iQUlUCany44CFv+OxY=", + "lastModified": 1768569498, + "narHash": "sha256-bB6Nt99Cj8Nu5nIUq0GLmpiErIT5KFshMQJGMZwgqUo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "f4606b01b39e09065df37905a2133905246db9ed", + "rev": "be5afa0fcb31f0a96bf9ecba05a516c66fcd8114", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 32614640ad3..e4d214a0b93 100644 --- a/flake.nix +++ b/flake.nix @@ -6,11 +6,7 @@ }; outputs = - { - self, - nixpkgs, - ... - }: + { self, nixpkgs, ... }: let systems = [ "aarch64-linux" @@ -18,100 +14,56 @@ "aarch64-darwin" "x86_64-darwin" ]; - inherit (nixpkgs) lib; - forEachSystem = lib.genAttrs systems; - pkgsFor = system: nixpkgs.legacyPackages.${system}; - packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json); - bunTarget = { - "aarch64-linux" = "bun-linux-arm64"; - "x86_64-linux" = "bun-linux-x64"; - "aarch64-darwin" = "bun-darwin-arm64"; - "x86_64-darwin" = "bun-darwin-x64"; - }; - - # Parse "bun-{os}-{cpu}" to {os, cpu} - parseBunTarget = - target: - let - parts = lib.splitString "-" target; - in - { - os = builtins.elemAt parts 1; - cpu = builtins.elemAt parts 2; - }; - - hashesFile = "${./nix}/hashes.json"; - hashesData = - if builtins.pathExists hashesFile then builtins.fromJSON (builtins.readFile hashesFile) else { }; - # Lookup hash: supports per-system ({system: hash}) or legacy single hash - nodeModulesHashFor = - system: - if builtins.isAttrs hashesData.nodeModules then - hashesData.nodeModules.${system} - else - hashesData.nodeModules; - modelsDev = forEachSystem ( - system: - let - pkgs = pkgsFor system; - in - pkgs."models-dev" - ); + forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + rev = self.shortRev or self.dirtyShortRev or "dirty"; in { - devShells = forEachSystem ( - system: - let - pkgs = pkgsFor system; - in - { - default = pkgs.mkShell { - packages = with pkgs; [ - bun - nodejs_20 - pkg-config - openssl - git - ]; - }; - } - ); + devShells = forEachSystem (pkgs: { + default = pkgs.mkShell { + packages = with pkgs; [ + bun + nodejs_20 + pkg-config + openssl + git + ]; + }; + }); packages = forEachSystem ( - system: + pkgs: let - pkgs = pkgsFor system; - bunPlatform = parseBunTarget bunTarget.${system}; - mkNodeModules = pkgs.callPackage ./nix/node-modules.nix { - hash = nodeModulesHashFor system; - bunCpu = bunPlatform.cpu; - bunOs = bunPlatform.os; + node_modules = pkgs.callPackage ./nix/node_modules.nix { + inherit rev; }; - mkOpencode = pkgs.callPackage ./nix/opencode.nix { }; - mkDesktop = pkgs.callPackage ./nix/desktop.nix { }; - - opencodePkg = mkOpencode { - inherit (packageJson) version; - src = ./.; - scripts = ./nix/scripts; - target = bunTarget.${system}; - modelsDev = "${modelsDev.${system}}/dist/_api.json"; - inherit mkNodeModules; + opencode = pkgs.callPackage ./nix/opencode.nix { + inherit node_modules; }; - - desktopPkg = mkDesktop { - inherit (packageJson) version; - src = ./.; - scripts = ./nix/scripts; - mkNodeModules = mkNodeModules; - opencode = opencodePkg; + desktop = pkgs.callPackage ./nix/desktop.nix { + inherit opencode; }; + # nixpkgs cpu naming to bun cpu naming + cpuMap = { x86_64 = "x64"; aarch64 = "arm64"; }; + # matrix of node_modules builds - these will always fail due to fakeHash usage + # but allow computation of the correct hash from any build machine for any cpu/os + # see the update-nix-hashes workflow for usage + moduleUpdaters = pkgs.lib.listToAttrs ( + pkgs.lib.concatMap (cpu: + map (os: { + name = "${cpu}-${os}_node_modules"; + value = node_modules.override { + bunCpu = cpuMap.${cpu}; + bunOs = os; + hash = pkgs.lib.fakeHash; + }; + }) [ "linux" "darwin" ] + ) [ "x86_64" "aarch64" ] + ); in { - default = self.packages.${system}.opencode; - opencode = opencodePkg; - desktop = desktopPkg; - } + default = opencode; + inherit opencode desktop; + } // moduleUpdaters ); }; } diff --git a/github/README.md b/github/README.md index 8238bdc42aa..17b24ffb1d6 100644 --- a/github/README.md +++ b/github/README.md @@ -91,8 +91,10 @@ This will walk you through installing the GitHub app, creating the workflow, and uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 + use_github_token: true ``` 3. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys. diff --git a/nix/bundle.ts b/nix/bundle.ts deleted file mode 100644 index 460865971dc..00000000000 --- a/nix/bundle.ts +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bun - -import solidPlugin from "./node_modules/@opentui/solid/scripts/solid-plugin" -import path from "path" -import fs from "fs" - -const dir = process.cwd() -const parser = fs.realpathSync(path.join(dir, "node_modules/@opentui/core/parser.worker.js")) -const worker = "./src/cli/cmd/tui/worker.ts" -const version = process.env.OPENCODE_VERSION ?? "local" -const channel = process.env.OPENCODE_CHANNEL ?? "local" -const base = process.env.OPENCODE_BASE_VERSION ?? version - -fs.rmSync(path.join(dir, "dist"), { recursive: true, force: true }) - -const result = await Bun.build({ - entrypoints: ["./src/index.ts", worker, parser], - outdir: "./dist", - target: "bun", - sourcemap: "none", - tsconfig: "./tsconfig.json", - plugins: [solidPlugin], - external: ["@opentui/core"], - define: { - OPENCODE_VERSION: `'${version}'`, - OPENCODE_BASE_VERSION: `'${base}'`, - OPENCODE_CHANNEL: `'${channel}'`, - // Leave undefined so runtime picks bundled/dist worker or fallback in code. - OPENCODE_WORKER_PATH: "undefined", - OTUI_TREE_SITTER_WORKER_PATH: 'new URL("./cli/cmd/tui/parser.worker.js", import.meta.url).href', - }, -}) - -if (!result.success) { - console.error("bundle failed") - for (const log of result.logs) console.error(log) - process.exit(1) -} - -const parserOut = path.join(dir, "dist/src/cli/cmd/tui/parser.worker.js") -fs.mkdirSync(path.dirname(parserOut), { recursive: true }) -await Bun.write(parserOut, Bun.file(parser)) diff --git a/nix/desktop.nix b/nix/desktop.nix index 9fb73b56316..9625f75c271 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -2,166 +2,99 @@ 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-tauri, + bun, + nodejs, cargo, rustc, - makeBinaryWrapper, - copyDesktopItems, - makeDesktopItem, - nodejs, jq, + wrapGAppsHook4, + makeWrapper, + dbus, + glib, + gtk4, + libsoup_3, + librsvg, + libappindicator, + glib-networking, + openssl, + webkitgtk_4_1, + gst_all_1, + opencode, }: -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 { +rustPlatform.buildRustPackage (finalAttrs: { pname = "opencode-desktop"; - version = args.version; + inherit (opencode) + version + src + node_modules + patches + ; - 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; - }; + cargoRoot = "packages/desktop/src-tauri"; + cargoLock.lockFile = ../packages/desktop/src-tauri/Cargo.lock; + buildAndTestSubdir = finalAttrs.cargoRoot; nativeBuildInputs = [ pkg-config + cargo-tauri.hook bun - makeBinaryWrapper - copyDesktopItems + nodejs # for patchShebangs node_modules cargo rustc - nodejs jq - ]; - - # based on packages/desktop/src-tauri/release/appstream.metainfo.xml - desktopItems = lib.optionals stdenv.isLinux [ - (makeDesktopItem { - name = "ai.opencode.opencode"; - desktopName = "OpenCode"; - comment = "Open source AI coding agent"; - exec = "opencode-desktop"; - icon = "opencode"; - terminal = false; - type = "Application"; - categories = [ "Development" "IDE" ]; - startupWMClass = "opencode"; - }) - ]; - - buildInputs = [ - openssl + makeWrapper ] - ++ lib.optionals stdenv.isLinux [ + ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ]; + + buildInputs = lib.optionals stdenv.isLinux [ dbus glib - gtk3 + gtk4 libsoup_3 - webkitgtk_4_1 librsvg - libappindicator-gtk3 + libappindicator + glib-networking + openssl + webkitgtk_4_1 + gst_all_1.gstreamer + gst_all_1.gst-plugins-base + gst_all_1.gst-plugins-good ]; - 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 . + strictDeps = true; - # 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 + preBuild = '' + cp -a ${finalAttrs.node_modules}/{node_modules,packages} . + chmod -R u+w node_modules packages patchShebangs node_modules + patchShebangs packages/desktop/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 + cp ${opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-${stdenv.hostPlatform.rust.rustcTarget} ''; - # 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 '' - # Install icon - mkdir -p $out/share/icons/hicolor/128x128/apps - cp ../../../packages/desktop/src-tauri/icons/prod/128x128.png $out/share/icons/hicolor/128x128/apps/opencode.png + # see publish-tauri job in .github/workflows/publish.yml + tauriBuildFlags = [ + "--config" + "tauri.prod.conf.json" + "--no-sign" # no code signing or auto updates + ]; - # 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 - ] - } + # FIXME: workaround for concerns about case insensitive filesystems + # should be removed once binary is renamed or decided otherwise + # darwin output is a .app bundle so no conflict + postFixup = lib.optionalString stdenv.hostPlatform.isLinux '' + mv $out/bin/OpenCode $out/bin/opencode-desktop + sed -i 's|^Exec=OpenCode$|Exec=opencode-desktop|' $out/share/applications/OpenCode.desktop ''; - meta = with lib; { + meta = { description = "OpenCode Desktop App"; homepage = "https://opencode.ai"; - license = licenses.mit; - maintainers = with maintainers; [ ]; + license = lib.licenses.mit; mainProgram = "opencode-desktop"; - platforms = platforms.linux ++ platforms.darwin; + inherit (opencode.meta) platforms; }; -} +}) \ No newline at end of file diff --git a/nix/hashes.json b/nix/hashes.json index 16a1c1f398b..fa91b3b3102 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-4zchRpxzvHnPMcwumgL9yaX0deIXS5IGPp131eYsSvg=", - "aarch64-linux": "sha256-3/BSRsl5pI0Iz3qAFZxIkOehFLZ2Ox9UsbdDHYzqlVg=", - "aarch64-darwin": "sha256-86d/G1q6xiHSSlm+/irXoKLb/yLQbV348uuSrBV70+Q=", - "x86_64-darwin": "sha256-WYaP44PWRGtoG1DIuUJUH4DvuaCuFhlJZ9fPzGsiIfE=" + "x86_64-linux": "sha256-80+b7FwUy4mRWTzEjPrBWuR5Um67I1Rn4U/n/s/lBjs=", + "aarch64-linux": "sha256-xH/Grwh3b+HWsUkKN8LMcyMaMcmnIJYlgp38WJCat5E=", + "aarch64-darwin": "sha256-Izv6PE9gNaeYYfcqDwjTU/WYtD1y+j65annwvLzkMD8=", + "x86_64-darwin": "sha256-EG1Z0uAeyFiOeVsv0Sz1sa8/mdXuw/uvbYYrkFR3EAg=" } } diff --git a/nix/node-modules.nix b/nix/node-modules.nix deleted file mode 100644 index 2a8f0a47cb0..00000000000 --- a/nix/node-modules.nix +++ /dev/null @@ -1,62 +0,0 @@ -{ - hash, - lib, - stdenvNoCC, - bun, - cacert, - curl, - bunCpu, - bunOs, -}: -args: -stdenvNoCC.mkDerivation { - pname = "opencode-node_modules"; - inherit (args) version src; - - impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [ - "GIT_PROXY_COMMAND" - "SOCKS_SERVER" - ]; - - nativeBuildInputs = [ - bun - cacert - curl - ]; - - dontConfigure = true; - - buildPhase = '' - runHook preBuild - export HOME=$(mktemp -d) - export BUN_INSTALL_CACHE_DIR=$(mktemp -d) - bun install \ - --cpu="${bunCpu}" \ - --os="${bunOs}" \ - --frozen-lockfile \ - --ignore-scripts \ - --no-progress \ - --linker=isolated - bun --bun ${args.canonicalizeScript} - bun --bun ${args.normalizeBinsScript} - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - mkdir -p $out - while IFS= read -r dir; do - rel="''${dir#./}" - dest="$out/$rel" - mkdir -p "$(dirname "$dest")" - cp -R "$dir" "$dest" - done < <(find . -type d -name node_modules -prune | sort) - runHook postInstall - ''; - - dontFixup = true; - - outputHashAlgo = "sha256"; - outputHashMode = "recursive"; - outputHash = hash; -} diff --git a/nix/node_modules.nix b/nix/node_modules.nix new file mode 100644 index 00000000000..981a60ef9ba --- /dev/null +++ b/nix/node_modules.nix @@ -0,0 +1,85 @@ +{ + lib, + stdenvNoCC, + bun, + bunCpu ? if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64", + bunOs ? if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin", + rev ? "dirty", + hash ? + (lib.pipe ./hashes.json [ + builtins.readFile + builtins.fromJSON + ]).nodeModules.${stdenvNoCC.hostPlatform.system}, +}: +let + packageJson = lib.pipe ../packages/opencode/package.json [ + builtins.readFile + builtins.fromJSON + ]; +in +stdenvNoCC.mkDerivation { + pname = "opencode-node_modules"; + version = "${packageJson.version}-${rev}"; + + src = lib.fileset.toSource { + root = ../.; + fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) ( + lib.fileset.unions [ + ../packages + ../bun.lock + ../package.json + ../patches + ../install + ] + ); + }; + + impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [ + "GIT_PROXY_COMMAND" + "SOCKS_SERVER" + ]; + + nativeBuildInputs = [ + bun + ]; + + dontConfigure = true; + + buildPhase = '' + runHook preBuild + export HOME=$(mktemp -d) + export BUN_INSTALL_CACHE_DIR=$(mktemp -d) + bun install \ + --cpu="${bunCpu}" \ + --os="${bunOs}" \ + --frozen-lockfile \ + --ignore-scripts \ + --no-progress \ + --linker=isolated + bun --bun ${./scripts/canonicalize-node-modules.ts} + bun --bun ${./scripts/normalize-bun-binaries.ts} + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out + find . -type d -name node_modules -exec cp -R --parents {} $out \; + + runHook postInstall + ''; + + dontFixup = true; + + outputHashAlgo = "sha256"; + outputHashMode = "recursive"; + outputHash = hash; + + meta.platforms = [ + "aarch64-linux" + "x86_64-linux" + "aarch64-darwin" + "x86_64-darwin" + ]; +} diff --git a/nix/opencode.nix b/nix/opencode.nix index 714aabe094f..23d9fbe34e0 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -1,61 +1,48 @@ { lib, stdenvNoCC, + callPackage, bun, - ripgrep, + sysctl, makeBinaryWrapper, + models-dev, + ripgrep, + installShellFiles, + versionCheckHook, + writableTmpDirAsHomeHook, + node_modules ? callPackage ./node-modules.nix { }, }: -args: -let - inherit (args) scripts; - mkModules = - attrs: - args.mkNodeModules ( - attrs - // { - canonicalizeScript = scripts + "/canonicalize-node-modules.ts"; - normalizeBinsScript = scripts + "/normalize-bun-binaries.ts"; - } - ); -in stdenvNoCC.mkDerivation (finalAttrs: { pname = "opencode"; - inherit (args) version src; - - node_modules = mkModules { - inherit (finalAttrs) version src; - }; + inherit (node_modules) version src; + inherit node_modules; nativeBuildInputs = [ bun + installShellFiles makeBinaryWrapper + models-dev + writableTmpDirAsHomeHook ]; - env.MODELS_DEV_API_JSON = args.modelsDev; - env.OPENCODE_VERSION = args.version; - env.OPENCODE_CHANNEL = "stable"; - dontConfigure = true; + configurePhase = '' + runHook preConfigure - buildPhase = '' - runHook preBuild + cp -R ${finalAttrs.node_modules}/. . - cp -r ${finalAttrs.node_modules}/node_modules . - cp -r ${finalAttrs.node_modules}/packages . + runHook postConfigure + ''; - ( - cd packages/opencode + env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json"; + env.OPENCODE_VERSION = finalAttrs.version; + env.OPENCODE_CHANNEL = "local"; - chmod -R u+w ./node_modules - mkdir -p ./node_modules/@opencode-ai - rm -f ./node_modules/@opencode-ai/{script,sdk,plugin} - ln -s $(pwd)/../../packages/script ./node_modules/@opencode-ai/script - ln -s $(pwd)/../../packages/sdk/js ./node_modules/@opencode-ai/sdk - ln -s $(pwd)/../../packages/plugin ./node_modules/@opencode-ai/plugin + buildPhase = '' + runHook preBuild - cp ${./bundle.ts} ./bundle.ts - chmod +x ./bundle.ts - bun run ./bundle.ts - ) + cd ./packages/opencode + bun --bun ./script/build.ts --single --skip-install + bun --bun ./script/schema.ts schema.json runHook postBuild ''; @@ -63,76 +50,47 @@ stdenvNoCC.mkDerivation (finalAttrs: { installPhase = '' runHook preInstall - cd packages/opencode - if [ ! -d dist ]; then - echo "ERROR: dist directory missing after bundle step" - exit 1 - fi - - mkdir -p $out/lib/opencode - cp -r dist $out/lib/opencode/ - chmod -R u+w $out/lib/opencode/dist - - # Select bundled worker assets deterministically (sorted find output) - worker_file=$(find "$out/lib/opencode/dist" -type f \( -path '*/tui/worker.*' -o -name 'worker.*' \) | sort | head -n1) - parser_worker_file=$(find "$out/lib/opencode/dist" -type f -name 'parser.worker.*' | sort | head -n1) - if [ -z "$worker_file" ]; then - echo "ERROR: bundled worker not found" - exit 1 - fi - - main_wasm=$(printf '%s\n' "$out"/lib/opencode/dist/tree-sitter-*.wasm | sort | head -n1) - wasm_list=$(find "$out/lib/opencode/dist" -maxdepth 1 -name 'tree-sitter-*.wasm' -print) - for patch_file in "$worker_file" "$parser_worker_file"; do - [ -z "$patch_file" ] && continue - [ ! -f "$patch_file" ] && continue - if [ -n "$wasm_list" ] && grep -q 'tree-sitter' "$patch_file"; then - # Rewrite wasm references to absolute store paths to avoid runtime resolve failures. - bun --bun ${scripts + "/patch-wasm.ts"} "$patch_file" "$main_wasm" $wasm_list - fi - done - - mkdir -p $out/lib/opencode/node_modules - cp -r ../../node_modules/.bun $out/lib/opencode/node_modules/ - mkdir -p $out/lib/opencode/node_modules/@opentui - - mkdir -p $out/bin - makeWrapper ${bun}/bin/bun $out/bin/opencode \ - --add-flags "run" \ - --add-flags "$out/lib/opencode/dist/src/index.js" \ - --prefix PATH : ${lib.makeBinPath [ ripgrep ]} \ - --argv0 opencode + install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode + install -Dm644 schema.json $out/share/opencode/schema.json + + wrapProgram $out/bin/opencode \ + --prefix PATH : ${ + lib.makeBinPath ( + [ + ripgrep + ] + # bun runs sysctl to detect if dunning on rosetta2 + ++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl + ) + } runHook postInstall ''; - postInstall = '' - for pkg in $out/lib/opencode/node_modules/.bun/@opentui+core-* $out/lib/opencode/node_modules/.bun/@opentui+solid-* $out/lib/opencode/node_modules/.bun/@opentui+core@* $out/lib/opencode/node_modules/.bun/@opentui+solid@*; do - if [ -d "$pkg" ]; then - pkgName=$(basename "$pkg" | sed 's/@opentui+\([^@]*\)@.*/\1/') - ln -sf ../.bun/$(basename "$pkg")/node_modules/@opentui/$pkgName \ - $out/lib/opencode/node_modules/@opentui/$pkgName - fi - done + postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) '' + # trick yargs into also generating zsh completions + installShellCompletion --cmd opencode \ + --bash <($out/bin/opencode completion) \ + --zsh <(SHELL=/bin/zsh $out/bin/opencode completion) ''; - dontFixup = true; + nativeInstallCheckInputs = [ + versionCheckHook + writableTmpDirAsHomeHook + ]; + doInstallCheck = true; + versionCheckKeepEnvironment = [ "HOME" ]; + versionCheckProgramArg = "--version"; + + passthru = { + jsonschema = "${placeholder "out"}/share/opencode/schema.json"; + }; meta = { - description = "AI coding agent built for the terminal"; - longDescription = '' - OpenCode is a terminal-based agent that can build anything. - It combines a TypeScript/JavaScript core with a Go-based TUI - to provide an interactive AI coding experience. - ''; - homepage = "https://github.com/anomalyco/opencode"; + description = "The open source coding agent"; + homepage = "https://opencode.ai/"; license = lib.licenses.mit; - platforms = [ - "aarch64-linux" - "x86_64-linux" - "aarch64-darwin" - "x86_64-darwin" - ]; mainProgram = "opencode"; + inherit (node_modules.meta) platforms; }; }) diff --git a/nix/scripts/bun-build.ts b/nix/scripts/bun-build.ts deleted file mode 100644 index bbca0cb8e94..00000000000 --- a/nix/scripts/bun-build.ts +++ /dev/null @@ -1,123 +0,0 @@ -import solidPlugin from "./packages/opencode/node_modules/@opentui/solid/scripts/solid-plugin" -import path from "path" -import fs from "fs" - -const version = "@VERSION@" -const pkg = path.join(process.cwd(), "packages/opencode") -const pkgjson = JSON.parse(fs.readFileSync(path.join(pkg, "package.json"), "utf8")) as { version?: string } -const base = pkgjson.version ?? version -const parser = fs.realpathSync(path.join(pkg, "./node_modules/@opentui/core/parser.worker.js")) -const worker = "./src/cli/cmd/tui/worker.ts" -const target = process.env["BUN_COMPILE_TARGET"] - -if (!target) { - throw new Error("BUN_COMPILE_TARGET not set") -} - -process.chdir(pkg) - -const manifestName = "opencode-assets.manifest" -const manifestPath = path.join(pkg, manifestName) - -const readTrackedAssets = () => { - if (!fs.existsSync(manifestPath)) return [] - return fs - .readFileSync(manifestPath, "utf8") - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) -} - -const removeTrackedAssets = () => { - for (const file of readTrackedAssets()) { - const filePath = path.join(pkg, file) - if (fs.existsSync(filePath)) { - fs.rmSync(filePath, { force: true }) - } - } -} - -const assets = new Set() - -const addAsset = async (p: string) => { - const file = path.basename(p) - const dest = path.join(pkg, file) - await Bun.write(dest, Bun.file(p)) - assets.add(file) -} - -removeTrackedAssets() - -const result = await Bun.build({ - conditions: ["browser"], - tsconfig: "./tsconfig.json", - plugins: [solidPlugin], - sourcemap: "external", - entrypoints: ["./src/index.ts", parser, worker], - define: { - OPENCODE_VERSION: `'@VERSION@'`, - OPENCODE_BASE_VERSION: `'${base}'`, - OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(pkg, parser).replace(/\\/g, "/"), - OPENCODE_CHANNEL: "'latest'", - }, - compile: { - target, - outfile: "opencode", - 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: {}, - }, -}) - -if (!result.success) { - console.error("Build failed!") - for (const log of result.logs) { - console.error(log) - } - throw new Error("Compilation failed") -} - -const assetOutputs = result.outputs?.filter((x) => x.kind === "asset") ?? [] -for (const x of assetOutputs) { - await addAsset(x.path) -} - -const bundle = await Bun.build({ - entrypoints: [worker], - tsconfig: "./tsconfig.json", - plugins: [solidPlugin], - target: "bun", - outdir: "./.opencode-worker", - sourcemap: "none", -}) - -if (!bundle.success) { - console.error("Worker build failed!") - for (const log of bundle.logs) { - console.error(log) - } - throw new Error("Worker compilation failed") -} - -const workerAssets = bundle.outputs?.filter((x) => x.kind === "asset") ?? [] -for (const x of workerAssets) { - await addAsset(x.path) -} - -const output = bundle.outputs.find((x) => x.kind === "entry-point") -if (!output) { - throw new Error("Worker build produced no entry-point output") -} - -const dest = path.join(pkg, "opencode-worker.js") -await Bun.write(dest, Bun.file(output.path)) -fs.rmSync(path.dirname(output.path), { recursive: true, force: true }) - -const list = Array.from(assets) -await Bun.write(manifestPath, list.length > 0 ? list.join("\n") + "\n" : "") - -console.log("Build successful!") diff --git a/nix/scripts/patch-wasm.ts b/nix/scripts/patch-wasm.ts deleted file mode 100644 index 88a06c2bd2b..00000000000 --- a/nix/scripts/patch-wasm.ts +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bun - -import fs from "fs" -import path from "path" - -/** - * Rewrite tree-sitter wasm references inside a JS file to absolute paths. - * argv: [node, script, file, mainWasm, ...wasmPaths] - */ -const [, , file, mainWasm, ...wasmPaths] = process.argv - -if (!file || !mainWasm) { - console.error("usage: patch-wasm [wasmPaths...]") - process.exit(1) -} - -const content = fs.readFileSync(file, "utf8") -const byName = new Map() - -for (const wasm of wasmPaths) { - const name = path.basename(wasm) - byName.set(name, wasm) -} - -let next = content - -for (const [name, wasmPath] of byName) { - next = next.replaceAll(name, wasmPath) -} - -next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm) - -// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...") -const nixStorePrefix = process.env.NIX_STORE || "/nix/store" -next = next.replace(/(\.\/)+/g, "./") -next = next.replace( - new RegExp(`(\\.\\.\\/)+\\/{1,2}(${nixStorePrefix.replace(/^\//, "").replace(/\//g, "\\/")}[^"']+)`, "g"), - "/$2", -) -next = next.replace(new RegExp(`(["'])\\/{2,}(\\/${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3") -next = next.replace(new RegExp(`(["'])\\/\\/(${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3") - -if (next !== content) fs.writeFileSync(file, next) diff --git a/package.json b/package.json index f19f54c9005..a3a8b9f545c 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "luxon": "3.6.1", "marked": "17.0.1", "marked-shiki": "1.2.1", + "@playwright/test": "1.51.0", "typescript": "5.8.2", "@typescript/native-preview": "7.0.0-dev.20251207.1", "zod": "4.1.8", diff --git a/packages/app/.gitignore b/packages/app/.gitignore index 5440503b1a1..d699efb38d2 100644 --- a/packages/app/.gitignore +++ b/packages/app/.gitignore @@ -1,2 +1,3 @@ src/assets/theme.css -dev-dist/ +e2e/test-results +e2e/playwright-report diff --git a/packages/app/README.md b/packages/app/README.md index bd10e6c8ddf..42a68815090 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -29,6 +29,21 @@ It correctly bundles Solid in production mode and optimizes the build for the be The build is minified and the filenames include the hashes.
Your app is ready to be deployed! +## E2E Testing + +The Playwright runner expects the app already running at `http://localhost:3000`. + +```bash +bun add -D @playwright/test +bunx playwright install +bun run test:e2e +``` + +Environment options: + +- `PLAYWRIGHT_BASE_URL` (default: `http://localhost:3000`) +- `PLAYWRIGHT_PORT` (default: `3000`) + ## Deployment You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) diff --git a/packages/app/e2e/context.spec.ts b/packages/app/e2e/context.spec.ts new file mode 100644 index 00000000000..beabd2eb7dd --- /dev/null +++ b/packages/app/e2e/context.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "./fixtures" +import { promptSelector } from "./utils" + +test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => { + const title = `e2e smoke context ${Date.now()}` + const created = await sdk.session.create({ title }).then((r) => r.data) + + if (!created?.id) throw new Error("Session create did not return an id") + const sessionID = created.id + + try { + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [ + { + type: "text", + text: "seed context", + }, + ], + }) + + await expect + .poll(async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + return messages.length + }) + .toBeGreaterThan(0) + + await gotoSession(sessionID) + + const contextButton = page + .locator('[data-component="button"]') + .filter({ has: page.locator('[data-component="progress-circle"]').first() }) + .first() + + await expect(contextButton).toBeVisible() + await contextButton.click() + + const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') + await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible() + } finally { + await sdk.session.delete({ sessionID }).catch(() => undefined) + } +}) diff --git a/packages/app/e2e/file-open.spec.ts b/packages/app/e2e/file-open.spec.ts new file mode 100644 index 00000000000..fb7104b6b05 --- /dev/null +++ b/packages/app/e2e/file-open.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from "./fixtures" +import { modKey } from "./utils" + +test("can open a file tab from the search palette", async ({ page, gotoSession }) => { + await gotoSession() + + await page.keyboard.press(`${modKey}+P`) + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + + const input = dialog.getByRole("textbox").first() + await input.fill("package.json") + + const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first() + await expect(fileItem).toBeVisible() + await fileItem.click() + + await expect(dialog).toHaveCount(0) + + const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') + await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible() +}) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts new file mode 100644 index 00000000000..721d60049ce --- /dev/null +++ b/packages/app/e2e/fixtures.ts @@ -0,0 +1,40 @@ +import { test as base, expect } from "@playwright/test" +import { createSdk, dirSlug, getWorktree, promptSelector, sessionPath } from "./utils" + +type TestFixtures = { + sdk: ReturnType + gotoSession: (sessionID?: string) => Promise +} + +type WorkerFixtures = { + directory: string + slug: string +} + +export const test = base.extend({ + directory: [ + async ({}, use) => { + const directory = await getWorktree() + await use(directory) + }, + { scope: "worker" }, + ], + slug: [ + async ({ directory }, use) => { + await use(dirSlug(directory)) + }, + { scope: "worker" }, + ], + sdk: async ({ directory }, use) => { + await use(createSdk(directory)) + }, + gotoSession: async ({ page, directory }, use) => { + const gotoSession = async (sessionID?: string) => { + await page.goto(sessionPath(directory, sessionID)) + await expect(page.locator(promptSelector)).toBeVisible() + } + await use(gotoSession) + }, +}) + +export { expect } diff --git a/packages/app/e2e/home.spec.ts b/packages/app/e2e/home.spec.ts new file mode 100644 index 00000000000..c6fb0e3b074 --- /dev/null +++ b/packages/app/e2e/home.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "./fixtures" +import { serverName } from "./utils" + +test("home renders and shows core entrypoints", async ({ page }) => { + await page.goto("/") + + await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() + await expect(page.getByRole("button", { name: serverName })).toBeVisible() +}) + +test("server picker dialog opens from home", async ({ page }) => { + await page.goto("/") + + const trigger = page.getByRole("button", { name: serverName }) + await expect(trigger).toBeVisible() + await trigger.click() + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("textbox").first()).toBeVisible() +}) diff --git a/packages/app/e2e/navigation.spec.ts b/packages/app/e2e/navigation.spec.ts new file mode 100644 index 00000000000..76923af6ede --- /dev/null +++ b/packages/app/e2e/navigation.spec.ts @@ -0,0 +1,9 @@ +import { test, expect } from "./fixtures" +import { dirPath, promptSelector } from "./utils" + +test("project route redirects to /session", async ({ page, directory, slug }) => { + await page.goto(dirPath(directory)) + + await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) + await expect(page.locator(promptSelector)).toBeVisible() +}) diff --git a/packages/app/e2e/palette.spec.ts b/packages/app/e2e/palette.spec.ts new file mode 100644 index 00000000000..617c55ac167 --- /dev/null +++ b/packages/app/e2e/palette.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from "./fixtures" +import { modKey } from "./utils" + +test("search palette opens and closes", async ({ page, gotoSession }) => { + await gotoSession() + + await page.keyboard.press(`${modKey}+P`) + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("textbox").first()).toBeVisible() + + await page.keyboard.press("Escape") + await expect(dialog).toHaveCount(0) +}) diff --git a/packages/app/e2e/session.spec.ts b/packages/app/e2e/session.spec.ts new file mode 100644 index 00000000000..19e25a42131 --- /dev/null +++ b/packages/app/e2e/session.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "./fixtures" +import { promptSelector } from "./utils" + +test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => { + const title = `e2e smoke ${Date.now()}` + const created = await sdk.session.create({ title }).then((r) => r.data) + + if (!created?.id) throw new Error("Session create did not return an id") + const sessionID = created.id + + try { + await gotoSession(sessionID) + + const prompt = page.locator(promptSelector) + await prompt.click() + await page.keyboard.type("hello from e2e") + await expect(prompt).toContainText("hello from e2e") + } finally { + await sdk.session.delete({ sessionID }).catch(() => undefined) + } +}) diff --git a/packages/app/e2e/sidebar.spec.ts b/packages/app/e2e/sidebar.spec.ts new file mode 100644 index 00000000000..925590f5106 --- /dev/null +++ b/packages/app/e2e/sidebar.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "./fixtures" +import { modKey } from "./utils" + +test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { + await gotoSession() + + const main = page.locator("main") + const closedClass = /xl:border-l/ + const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l")) + + if (isClosed) { + await page.keyboard.press(`${modKey}+B`) + await expect(main).not.toHaveClass(closedClass) + } + + await page.keyboard.press(`${modKey}+B`) + await expect(main).toHaveClass(closedClass) + + await page.keyboard.press(`${modKey}+B`) + await expect(main).not.toHaveClass(closedClass) +}) diff --git a/packages/app/e2e/terminal.spec.ts b/packages/app/e2e/terminal.spec.ts new file mode 100644 index 00000000000..fc558b63259 --- /dev/null +++ b/packages/app/e2e/terminal.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from "./fixtures" +import { terminalSelector, terminalToggleKey } from "./utils" + +test("terminal panel can be toggled", async ({ page, gotoSession }) => { + await gotoSession() + + const terminal = page.locator(terminalSelector) + const initiallyOpen = await terminal.isVisible() + if (initiallyOpen) { + await page.keyboard.press(terminalToggleKey) + await expect(terminal).toHaveCount(0) + } + + await page.keyboard.press(terminalToggleKey) + await expect(terminal).toBeVisible() +}) diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts new file mode 100644 index 00000000000..eb0395950ae --- /dev/null +++ b/packages/app/e2e/utils.ts @@ -0,0 +1,38 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" + +export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" + +export const serverUrl = `http://${serverHost}:${serverPort}` +export const serverName = `${serverHost}:${serverPort}` + +export const modKey = process.platform === "darwin" ? "Meta" : "Control" +export const terminalToggleKey = "Control+Backquote" + +export const promptSelector = '[data-component="prompt-input"]' +export const terminalSelector = '[data-component="terminal"]' + +export function createSdk(directory?: string) { + return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true }) +} + +export async function getWorktree() { + const sdk = createSdk() + const result = await sdk.path.get() + const data = result.data + if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`) + return data.worktree +} + +export function dirSlug(directory: string) { + return base64Encode(directory) +} + +export function dirPath(directory: string) { + return `/${dirSlug(directory)}` +} + +export function sessionPath(directory: string, sessionID?: string) { + return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}` +} diff --git a/packages/app/package.json b/packages/app/package.json index 1429a0222ed..5694e2771a4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.25-1", + "version": "1.1.26", "description": "", "type": "module", "exports": { @@ -12,11 +12,16 @@ "start": "vite", "dev": "vite", "build": "vite build", - "serve": "vite preview" + "serve": "vite preview", + "test": "playwright test", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report e2e/playwright-report" }, "license": "MIT", "devDependencies": { "@happy-dom/global-registrator": "20.0.11", + "@playwright/test": "1.57.0", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts new file mode 100644 index 00000000000..10819e69ffe --- /dev/null +++ b/packages/app/playwright.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from "@playwright/test" + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000) +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}` +const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" +const command = `bun run dev -- --host 0.0.0.0 --port ${port}` +const reuse = !process.env.CI + +export default defineConfig({ + testDir: "./e2e", + outputDir: "./e2e/test-results", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], + webServer: { + command, + url: baseURL, + reuseExistingServer: reuse, + timeout: 120_000, + env: { + VITE_OPENCODE_SERVER_HOST: serverHost, + VITE_OPENCODE_SERVER_PORT: serverPort, + }, + }, + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}) diff --git a/packages/app/public/apple-touch-icon-v2.png b/packages/app/public/apple-touch-icon-v2.png new file mode 120000 index 00000000000..c0d4353db47 --- /dev/null +++ b/packages/app/public/apple-touch-icon-v2.png @@ -0,0 +1 @@ +../../ui/src/assets/favicon/apple-touch-icon-v2.png \ No newline at end of file diff --git a/packages/app/public/favicon-96x96-v2.png b/packages/app/public/favicon-96x96-v2.png new file mode 120000 index 00000000000..b3129f6bf91 --- /dev/null +++ b/packages/app/public/favicon-96x96-v2.png @@ -0,0 +1 @@ +../../ui/src/assets/favicon/favicon-96x96-v2.png \ No newline at end of file diff --git a/packages/app/public/favicon-v2.ico b/packages/app/public/favicon-v2.ico new file mode 120000 index 00000000000..d8527270af6 --- /dev/null +++ b/packages/app/public/favicon-v2.ico @@ -0,0 +1 @@ +../../ui/src/assets/favicon/favicon-v2.ico \ No newline at end of file diff --git a/packages/app/public/favicon-v2.svg b/packages/app/public/favicon-v2.svg new file mode 120000 index 00000000000..2600394ceae --- /dev/null +++ b/packages/app/public/favicon-v2.svg @@ -0,0 +1 @@ +../../ui/src/assets/favicon/favicon-v2.svg \ No newline at end of file diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5fbe46a4f40..32763764c6f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -29,7 +29,7 @@ import { Suspense } from "solid-js" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) -const Loading = () =>
Loading...
+const Loading = () =>
declare global { interface Window { diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 2f0f7db1f68..7acb766f808 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -22,16 +22,20 @@ export function DialogEditProject(props: { project: LocalProject }) { const [store, setStore] = createStore({ name: defaultName(), color: props.project.icon?.color || "pink", - iconUrl: props.project.icon?.url || "", + iconUrl: props.project.icon?.override || "", saving: false, }) const [dragOver, setDragOver] = createSignal(false) + const [iconHover, setIconHover] = createSignal(false) function handleFileSelect(file: File) { if (!file.type.startsWith("image/")) return const reader = new FileReader() - reader.onload = (e) => setStore("iconUrl", e.target?.result as string) + reader.onload = (e) => { + setStore("iconUrl", e.target?.result as string) + setIconHover(false) + } reader.readAsDataURL(file) } @@ -70,15 +74,15 @@ export function DialogEditProject(props: { project: LocalProject }) { await globalSDK.client.project.update({ projectID: props.project.id, name, - icon: { color: store.color, url: store.iconUrl }, + icon: { color: store.color, override: store.iconUrl }, }) setStore("saving", false) dialog.close() } return ( - -
+ +
-
+
setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
document.getElementById("icon-upload")?.click()} + onClick={() => { + if (store.iconUrl && iconHover()) { + clearIcon() + } else { + document.getElementById("icon-upload")?.click() + } + }} >
- - - +
+ +
+
+ +
-
- Click or drag an image - Recommended: 128x128px +
+ Recommended size 128x128px
@@ -140,20 +179,25 @@ export function DialogEditProject(props: { project: LocalProject }) {
-
+
{(color) => ( )} diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 432e531e192..0e8d69628bb 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -149,7 +149,7 @@ export function DialogSelectFile() { +
diff --git a/packages/app/src/components/font-picker.tsx b/packages/app/src/components/font-picker.tsx index 91f2243bb5a..b56f72d243c 100644 --- a/packages/app/src/components/font-picker.tsx +++ b/packages/app/src/components/font-picker.tsx @@ -26,7 +26,7 @@ function DialogSelectFont(props: { originalFont: string }) { dialog.close() } - async function handleActiveChange(font: FontDefinition | undefined) { + async function handleMove(font: FontDefinition | undefined) { if (!font) return const loaded = await ensureFontLoaded(font) @@ -46,7 +46,7 @@ function DialogSelectFont(props: { originalFont: string }) { current={currentFont()} filterKeys={["name", "family"]} onSelect={handleSelect} - onActiveChange={handleActiveChange} + onMove={handleMove} > {(font: FontDefinition) => (
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index ad4aa7cd006..86414d66396 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -300,7 +300,8 @@ export const PromptInput: Component = (props) => { event.stopPropagation() const items = Array.from(clipboardData.items) - const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + const fileItems = items.filter((item) => item.kind === "file") + const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) if (imageItems.length > 0) { for (const item of imageItems) { @@ -310,7 +311,16 @@ export const PromptInput: Component = (props) => { return } + if (fileItems.length > 0) { + showToast({ + title: "Unsupported paste", + description: "Only images or PDFs can be pasted here.", + }) + return + } + const plainText = clipboardData.getData("text/plain") ?? "" + if (!plainText) return addPart({ type: "text", content: plainText, start: 0, end: 0 }) } @@ -1067,7 +1077,16 @@ export const PromptInput: Component = (props) => { let session = info() if (!session && isNewSession) { - session = await client.session.create().then((x) => x.data ?? undefined) + session = await client.session + .create() + .then((x) => x.data ?? undefined) + .catch((err) => { + showToast({ + title: "Failed to create session", + description: errorMessage(err), + }) + return undefined + }) if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } if (!session) return diff --git a/packages/app/src/components/theme-picker.tsx b/packages/app/src/components/theme-picker.tsx index c10d85f8756..149483ade25 100644 --- a/packages/app/src/components/theme-picker.tsx +++ b/packages/app/src/components/theme-picker.tsx @@ -21,7 +21,7 @@ export function DialogSelectTheme(props: { originalTheme: string }) { dialog.close() } - function handleActiveChange(theme: Theme | undefined) { + function handleMove(theme: Theme | undefined) { if (!theme) return setPreviewTheme(theme.id) applyTheme(theme.id) @@ -37,7 +37,7 @@ export function DialogSelectTheme(props: { originalTheme: string }) { current={currentTheme()} filterKeys={["name", "id"]} onSelect={handleSelect} - onActiveChange={handleActiveChange} + onMove={handleMove} > {(theme: Theme) => (
@@ -80,7 +80,13 @@ export function ThemePicker(props: { class?: string; mobile?: boolean }) { } > - diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index a93ffc02454..d8dc13e2344 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -1,5 +1,6 @@ import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) @@ -122,6 +123,7 @@ export function formatKeybind(config: string): string { export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ name: "Command", init: () => { + const dialog = useDialog() const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) @@ -165,7 +167,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } const handleKeyDown = (event: KeyboardEvent) => { - if (suspended()) return + if (suspended() || dialog.active) return const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index c70e474113a..2521efc8df1 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -44,8 +44,6 @@ type Dialog = "provider" | "model" | "connect" type SessionView = { scroll: Record reviewOpen?: string[] - terminalOpened?: boolean - reviewPanelOpened?: boolean } export type LocalProject = Partial & { worktree: string; expanded: boolean } @@ -97,6 +95,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( state: "pane" as "pane" | "tab", width: REVIEW_PANE.DEFAULT_WIDTH as number, diffStyle: "split" as ReviewDiffStyle, + panelOpened: true, }, session: { width: 600, @@ -227,10 +226,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }) }) - const usedColors = new Set() + const [colors, setColors] = createStore>({}) - function pickAvailableColor(): AvatarColorKey { - const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c)) + function pickAvailableColor(used: Set): AvatarColorKey { + const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c)) if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)] return available[Math.floor(Math.random() * available.length)] } @@ -241,24 +240,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const metadata = projectID ? globalSync.data.project.find((x) => x.id === projectID) : globalSync.data.project.find((x) => x.worktree === project.worktree) - return [ - { - ...(metadata ?? {}), - ...project, - icon: { url: metadata?.icon?.url, color: metadata?.icon?.color }, + return { + ...(metadata ?? {}), + ...project, + icon: { + url: metadata?.icon?.url, + override: metadata?.icon?.override, + color: metadata?.icon?.color, }, - ] - } - - function colorize(project: LocalProject) { - if (project.icon?.color) return project - const color = pickAvailableColor() - usedColors.add(color) - project.icon = { ...project.icon, color } - if (project.id) { - globalSdk.client.project.update({ projectID: project.id, icon: { color } }) } - return project } const roots = createMemo(() => { @@ -296,8 +286,37 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }) }) - const enriched = createMemo(() => server.projects.list().flatMap(enrich)) - const list = createMemo(() => enriched().flatMap(colorize)) + const enriched = createMemo(() => server.projects.list().map(enrich)) + const list = createMemo(() => { + const projects = enriched() + return projects.map((project) => { + const color = project.icon?.color ?? colors[project.worktree] + if (!color) return project + const icon = project.icon ? { ...project.icon, color } : { color } + return { ...project, icon } + }) + }) + + createEffect(() => { + const projects = enriched() + if (projects.length === 0) return + + const used = new Set() + for (const project of projects) { + const color = project.icon?.color ?? colors[project.worktree] + if (color) used.add(color) + } + + for (const project of projects) { + if (project.icon?.color) continue + if (colors[project.worktree]) continue + const color = pickAvailableColor(used) + used.add(color) + setColors(project.worktree, color) + if (!project.id) continue + void globalSdk.client.project.update({ projectID: project.id, icon: { color } }) + } + }) onMount(() => { // Load project sessions @@ -457,31 +476,31 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( touch(sessionKey) scroll.seed(sessionKey) const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} }) - const terminalOpened = createMemo(() => s().terminalOpened ?? false) - const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true) + const terminalOpened = createMemo(() => store.terminal?.opened ?? false) + const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true) function setTerminalOpened(next: boolean) { - const current = store.sessionView[sessionKey] + const current = store.terminal if (!current) { - setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true }) + setStore("terminal", { height: 280, opened: next }) return } - const value = current.terminalOpened ?? false + const value = current.opened ?? false if (value === next) return - setStore("sessionView", sessionKey, "terminalOpened", next) + setStore("terminal", "opened", next) } function setReviewPanelOpened(next: boolean) { - const current = store.sessionView[sessionKey] + const current = store.review if (!current) { - setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next }) + setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) return } - const value = current.reviewPanelOpened ?? true + const value = current.panelOpened ?? true if (value === next) return - setStore("sessionView", sessionKey, "reviewPanelOpened", next) + setStore("review", "panelOpened", next) } return { @@ -522,8 +541,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( if (!current) { setStore("sessionView", sessionKey, { scroll: {}, - terminalOpened: false, - reviewPanelOpened: true, reviewOpen: open, }) return diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index a7753069cf9..709d7b899ac 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -25,11 +25,11 @@ type TerminalCacheEntry = { dispose: VoidFunction } -function createTerminalSession(sdk: ReturnType, dir: string, id: string | undefined) { - const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1` +function createTerminalSession(sdk: ReturnType, dir: string, session?: string) { + const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`] const [store, setStore, _, ready] = persisted( - Persist.scoped(dir, id, "terminal", [legacy]), + Persist.workspace(dir, "terminal", legacy), createStore<{ active?: string all: LocalPTY[] @@ -43,17 +43,28 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: all: createMemo(() => Object.values(store.all)), active: createMemo(() => store.active), new() { + const parse = (title: string) => { + const match = title.match(/^Terminal (\d+)$/) + if (!match) return + const value = Number(match[1]) + if (!Number.isFinite(value) || value <= 0) return + return value + } + const existingTitleNumbers = new Set( - store.all.map((pty) => { - const match = pty.titleNumber - return match + store.all.flatMap((pty) => { + const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined + if (direct !== undefined) return [direct] + const parsed = parse(pty.title) + if (parsed === undefined) return [] + return [parsed] }), ) - let nextNumber = 1 - while (existingTitleNumbers.has(nextNumber)) { - nextNumber++ - } + const nextNumber = + Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find( + (number) => !existingTitleNumbers.has(number), + ) ?? 1 sdk.client.pty .create({ title: `Terminal ${nextNumber}` }) @@ -166,8 +177,8 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } } - const load = (dir: string, id: string | undefined) => { - const key = `${dir}:${id ?? WORKSPACE_KEY}` + const load = (dir: string, session?: string) => { + const key = `${dir}:${WORKSPACE_KEY}` const existing = cache.get(key) if (existing) { cache.delete(key) @@ -176,7 +187,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } const entry = createRoot((dispose) => ({ - value: createTerminalSession(sdk, dir, id), + value: createTerminalSession(sdk, dir, session), dispose, })) @@ -185,18 +196,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const session = createMemo(() => load(params.dir!, params.id)) + const workspace = createMemo(() => load(params.dir!, params.id)) return { - ready: () => session().ready(), - all: () => session().all(), - active: () => session().active(), - new: () => session().new(), - update: (pty: Partial & { id: string }) => session().update(pty), - clone: (id: string) => session().clone(id), - open: (id: string) => session().open(id), - close: (id: string) => session().close(id), - move: (id: string, to: number) => session().move(id, to), + ready: () => workspace().ready(), + all: () => workspace().all(), + active: () => workspace().active(), + new: () => workspace().new(), + update: (pty: Partial & { id: string }) => workspace().update(pty), + clone: (id: string) => workspace().clone(id), + open: (id: string) => workspace().open(id), + close: (id: string) => workspace().close(id), + move: (id: string, to: number) => workspace().move(id, to), } }, }) diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 28741098c8e..8c4662926ad 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -37,7 +37,7 @@ const platform: Platform = { .then(() => { const notification = new Notification(title, { body: description ?? "", - icon: "https://opencode.ai/favicon-96x96.png", + icon: "https://opencode.ai/favicon-96x96-v2.png", }) notification.onclick = () => { window.focus() diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c2b95a5f502..65d201efe00 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -18,7 +18,6 @@ import { useCodeComponent } from "@opencode-ai/ui/context/code" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { SessionReview } from "@opencode-ai/ui/session-review" -import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" @@ -1152,17 +1151,6 @@ export default function Page() { } >
- -
- -
-
{ diff --git a/packages/console/app/package.json b/packages/console/app/package.json index e0563ada655..83ff605cffa 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.25-1", + "version": "1.1.26", "type": "module", "license": "MIT", "scripts": { @@ -20,7 +20,7 @@ "@opencode-ai/console-mail": "workspace:*", "@opencode-ai/console-resource": "workspace:*", "@opencode-ai/ui": "workspace:*", - "@smithy/eventstream-codec": "4.2.8", + "@smithy/eventstream-codec": "4.2.7", "@smithy/util-utf8": "4.2.0", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 5d9083afbdb..eb86fb525b1 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.1.25-1", + "version": "1.1.26", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index f052e6fc6fe..36e8a76b79d 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -218,6 +218,7 @@ export namespace Billing { customer: customer.customerID, customer_update: { name: "auto", + address: "auto", }, } : { diff --git a/packages/console/core/src/util/date.test.ts b/packages/console/core/src/util/date.test.ts new file mode 100644 index 00000000000..074df8a2fad --- /dev/null +++ b/packages/console/core/src/util/date.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { getWeekBounds } from "./date" + +describe("util.date.getWeekBounds", () => { + test("returns a Monday-based week for Sunday dates", () => { + const date = new Date("2026-01-18T12:00:00Z") + const bounds = getWeekBounds(date) + + expect(bounds.start.toISOString()).toBe("2026-01-12T00:00:00.000Z") + expect(bounds.end.toISOString()).toBe("2026-01-19T00:00:00.000Z") + }) + + test("returns a seven day window", () => { + const date = new Date("2026-01-14T12:00:00Z") + const bounds = getWeekBounds(date) + + const span = bounds.end.getTime() - bounds.start.getTime() + expect(span).toBe(7 * 24 * 60 * 60 * 1000) + }) +}) diff --git a/packages/console/core/src/util/date.ts b/packages/console/core/src/util/date.ts index 7f34c9bb5eb..9c1ab12d2c9 100644 --- a/packages/console/core/src/util/date.ts +++ b/packages/console/core/src/util/date.ts @@ -1,7 +1,7 @@ export function getWeekBounds(date: Date) { - const dayOfWeek = date.getUTCDay() + const offset = (date.getUTCDay() + 6) % 7 const start = new Date(date) - start.setUTCDate(date.getUTCDate() - dayOfWeek + 1) + start.setUTCDate(date.getUTCDate() - offset) start.setUTCHours(0, 0, 0, 0) const end = new Date(start) end.setUTCDate(start.getUTCDate() + 7) diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 71153b70235..6cfdaab7579 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.25-1", + "version": "1.1.26", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/function/src/auth.ts b/packages/console/function/src/auth.ts index 082564b21ce..ee68dffff53 100644 --- a/packages/console/function/src/auth.ts +++ b/packages/console/function/src/auth.ts @@ -35,7 +35,7 @@ export const subjects = createSubjects({ const MY_THEME: Theme = { ...THEME_OPENAUTH, - logo: "https://opencode.ai/favicon.svg", + logo: "https://opencode.ai/favicon-v2.svg", } export default { diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index c4638836865..ab2fd76f8b1 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.25-1", + "version": "1.1.26", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index d501d57d66e..f0a6a369974 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@shuvcode/desktop", "private": true, - "version": "1.1.25-1", + "version": "1.1.26", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 8398f457766..a06270b13fe 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -12,7 +12,7 @@ import { relaunch } from "@tauri-apps/plugin-process" import { AsyncStorage } from "@solid-primitives/storage" import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { Store } from "@tauri-apps/plugin-store" -import { Logo } from "@opencode-ai/ui/logo" +import { Splash } from "@opencode-ai/ui/logo" import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import { UPDATER_ENABLED } from "./updater" @@ -26,17 +26,16 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { ) } -const isWindows = ostype() === "windows" -if (isWindows) { - const originalGetComputedStyle = window.getComputedStyle - window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => { - if (!(elt instanceof Element)) { - // WebView2 can call into Floating UI with non-elements; fall back to a safe element. - return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined) - } - return originalGetComputedStyle(elt, pseudoElt ?? undefined) - }) as typeof window.getComputedStyle -} +// Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements). +// This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows. +const originalGetComputedStyle = window.getComputedStyle +window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => { + if (!(elt instanceof Element)) { + // Fall back to a safe element when a non-element is passed. + return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined) + } + return originalGetComputedStyle(elt, pseudoElt ?? undefined) +}) as typeof window.getComputedStyle let update: Update | null = null @@ -254,7 +253,7 @@ const createPlatform = (password: Accessor): Platform => ({ .then(() => { const notification = new Notification(title, { body: description ?? "", - icon: "https://opencode.ai/favicon-96x96.png", + icon: "https://opencode.ai/favicon-96x96-v2.png", }) notification.onclick = () => { const win = getCurrentWindow() @@ -357,8 +356,7 @@ function ServerGate(props: { children: (data: Accessor) => JSX. when={serverData.state !== "pending" && serverData()} fallback={
- -
Initializing...
+
} > diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 4461f8253b7..93dff10f8c2 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -7,7 +7,7 @@ "light": "#07C983", "dark": "#15803D" }, - "favicon": "/favicon.svg", + "favicon": "/favicon-v2.svg", "navigation": { "tabs": [ { diff --git a/packages/docs/favicon-v2.svg b/packages/docs/favicon-v2.svg new file mode 100644 index 00000000000..b785c738bf1 --- /dev/null +++ b/packages/docs/favicon-v2.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index dee79cbeedf..1a40af1c799 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.25-1", + "version": "1.1.26", "private": true, "type": "module", "license": "MIT", diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index df3ecb9d35c..52d81c83e54 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -16,7 +16,6 @@ import { iife } from "@opencode-ai/util/iife" import { Binary } from "@opencode-ai/util/binary" import { NamedError } from "@opencode-ai/util/error" import { DateTime } from "luxon" -import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail" import { createStore } from "solid-js/store" import z from "zod" import NotFound from "../[...404]" @@ -26,6 +25,7 @@ import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr" import { clientOnly } from "@solidjs/start" import { type IconName } from "@opencode-ai/ui/icons/provider" import { Meta, Title } from "@solidjs/meta" +import { Base64 } from "js-base64" const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff }))) const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m) => ({ default: m.Code }))) @@ -185,8 +185,27 @@ export default function () { if (!match().found) throw new Error(`Session ${data().sessionID} not found`) const info = createMemo(() => data().session[match().index]) const ogImage = createMemo(() => { - // Use static fallback image for shuvcode - return `/social-share.png` + const models = new Set() + const messages = data().message[data().sessionID] ?? [] + for (const msg of messages) { + if (msg.role === "assistant" && msg.modelID) { + models.add(msg.modelID) + } + } + const modelIDs = Array.from(models) + const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(info().title.substring(0, 700)))) + let modelParam: string + if (modelIDs.length === 1) { + modelParam = modelIDs[0] + } else if (modelIDs.length === 2) { + modelParam = encodeURIComponent(`${modelIDs[0]} & ${modelIDs[1]}`) + } else if (modelIDs.length > 2) { + modelParam = encodeURIComponent(`${modelIDs[0]} & ${modelIDs.length - 1} others`) + } else { + modelParam = "unknown" + } + const version = `v${info().version}` + return `https://social-cards.sst.dev/opencode-share/${encodedTitle}.png?model=${modelParam}&version=${version}&id=${data().shareID}` }) return ( @@ -194,7 +213,7 @@ export default function () { {info().title} | shuvcode - + @@ -204,6 +223,7 @@ export default function () { {iife(() => { const [store, setStore] = createStore({ messageId: undefined as string | undefined, + expandedSteps: {} as Record, }) const messages = createMemo(() => data().sessionID @@ -245,20 +265,22 @@ export default function () { const title = () => (
-
-
- +
+
+
v{info().version}
-
- -
{model()?.name ?? modelID()}
-
-
- {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")} +
+
+ +
{model()?.name ?? modelID()}
+
+
+ {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")} +
{info().title}
@@ -266,18 +288,20 @@ export default function () { ) const turns = () => ( -
-
{title()}
+
+
{title()}
{(message) => ( setStore("expandedSteps", message.id, (v) => !v)} classes={{ root: "min-w-0 w-full relative", - content: - "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]", + content: "flex flex-col justify-between !overflow-visible", container: "px-4", }} /> @@ -285,7 +309,7 @@ export default function () {
- +
) @@ -295,8 +319,10 @@ export default function () { return (
-
- +
1, - "px-6": !wide() && messages().length === 1, + "w-full flex justify-start items-start min-w-0 px-6": true, }} > {title()}
- { + const id = store.messageId ?? firstUserMessage()!.id! + setStore("expandedSteps", id, (v) => !v) + }} classes={{ root: "grow", - content: "flex flex-col justify-between items-start", - container: - "w-full pb-20 " + - (wide() - ? "max-w-146 mx-auto px-6" - : messages().length > 1 - ? "pr-6 pl-18" - : "px-6"), + content: "flex flex-col justify-between", + container: "w-full pb-20 px-6", }} >
- +
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 191bd1af20a..2327c61e667 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.1.25-1" +version = "1.1.26" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25-1/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25-1/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25-1/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25-1/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25-1/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index df403243b48..e5b0f62b927 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.25-1", + "version": "1.1.26", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index aef9d2e2b9f..f60132a2cd6 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.25-1", + "version": "1.1.26", "name": "opencode", "type": "module", "license": "MIT", @@ -70,7 +70,7 @@ "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.1.1", + "@gitlab/gitlab-ai-provider": "3.1.2", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 3c0a4179d21..29b2bf6223b 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -95,6 +95,11 @@ const targets = singleFlag return baselineFlag } + // also skip abi-specific builds for the same reason + if (item.abi !== undefined) { + return false + } + return true }) : allTargets diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts new file mode 100644 index 00000000000..ba2155cb692 --- /dev/null +++ b/packages/opencode/script/seed-e2e.ts @@ -0,0 +1,50 @@ +const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd() +const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session" +const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e" +const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano" +const parts = model.split("/") +const providerID = parts[0] ?? "opencode" +const modelID = parts[1] ?? "gpt-5-nano" +const now = Date.now() + +const seed = async () => { + const { Instance } = await import("../src/project/instance") + const { InstanceBootstrap } = await import("../src/project/bootstrap") + const { Session } = await import("../src/session") + const { Identifier } = await import("../src/id/id") + const { Project } = await import("../src/project/project") + + await Instance.provide({ + directory: dir, + init: InstanceBootstrap, + fn: async () => { + const session = await Session.create({ title }) + const messageID = Identifier.descending("message") + const partID = Identifier.descending("part") + const message = { + id: messageID, + sessionID: session.id, + role: "user" as const, + time: { created: now }, + agent: "build", + model: { + providerID, + modelID, + }, + } + const part = { + id: partID, + sessionID: session.id, + messageID, + type: "text" as const, + text, + time: { start: now }, + } + await Session.updateMessage(message) + await Session.updatePart(part) + await Project.update({ projectID: Instance.project.id, name: "E2E Project" }) + }, + }) +} + +await seed() diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index de8904c0928..818d3e828a9 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -20,7 +20,7 @@ import { } from "@agentclientprotocol/sdk" import { Log } from "../util/log" import { ACPSessionManager } from "./session" -import type { ACPConfig, ACPSessionState } from "./types" +import type { ACPConfig } from "./types" import { Provider } from "../provider/provider" import { Agent as AgentModule } from "../agent/agent" import { Installation } from "@/installation" @@ -29,7 +29,7 @@ import { Config } from "@/config/config" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" -import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" +import type { Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" export namespace ACP { @@ -47,304 +47,354 @@ export namespace ACP { private connection: AgentSideConnection private config: ACPConfig private sdk: OpencodeClient - private sessionManager + private sessionManager: ACPSessionManager + private eventAbort = new AbortController() + private eventStarted = false + private permissionQueues = new Map>() + private permissionOptions: PermissionOption[] = [ + { optionId: "once", kind: "allow_once", name: "Allow once" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, + ] constructor(connection: AgentSideConnection, config: ACPConfig) { this.connection = connection this.config = config this.sdk = config.sdk this.sessionManager = new ACPSessionManager(this.sdk) + this.startEventSubscription() } - private setupEventSubscriptions(session: ACPSessionState) { - const sessionId = session.id - const directory = session.cwd + private startEventSubscription() { + if (this.eventStarted) return + this.eventStarted = true + this.runEventSubscription().catch((error) => { + if (this.eventAbort.signal.aborted) return + log.error("event subscription failed", { error }) + }) + } - const options: PermissionOption[] = [ - { optionId: "once", kind: "allow_once", name: "Allow once" }, - { optionId: "always", kind: "allow_always", name: "Always allow" }, - { optionId: "reject", kind: "reject_once", name: "Reject" }, - ] - this.config.sdk.event.subscribe({ directory }).then(async (events) => { + private async runEventSubscription() { + while (true) { + if (this.eventAbort.signal.aborted) return + const events = await this.sdk.global.event({ + signal: this.eventAbort.signal, + }) for await (const event of events.stream) { - switch (event.type) { - case "permission.asked": - try { - const permission = event.properties - const res = await this.connection - .requestPermission({ - sessionId, - toolCall: { - toolCallId: permission.tool?.callID ?? permission.id, - status: "pending", - title: permission.permission, - rawInput: permission.metadata, - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), - }, - options, - }) - .catch(async (error) => { - log.error("failed to request permission from ACP", { - error, - permissionID: permission.id, - sessionID: permission.sessionID, - }) - await this.config.sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - directory, - }) - return + if (this.eventAbort.signal.aborted) return + const payload = (event as any)?.payload + if (!payload) continue + await this.handleEvent(payload as Event).catch((error) => { + log.error("failed to handle event", { error, type: payload.type }) + }) + } + } + } + + private async handleEvent(event: Event) { + switch (event.type) { + case "permission.asked": { + const permission = event.properties + const session = this.sessionManager.tryGet(permission.sessionID) + if (!session) return + + const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve() + const next = prev + .then(async () => { + const directory = session.cwd + + const res = await this.connection + .requestPermission({ + sessionId: permission.sessionID, + toolCall: { + toolCallId: permission.tool?.callID ?? permission.id, + status: "pending", + title: permission.permission, + rawInput: permission.metadata, + kind: toToolKind(permission.permission), + locations: toLocations(permission.permission, permission.metadata), + }, + options: this.permissionOptions, + }) + .catch(async (error) => { + log.error("failed to request permission from ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, }) - if (!res) return - if (res.outcome.outcome !== "selected") { - await this.config.sdk.permission.reply({ + await this.sdk.permission.reply({ requestID: permission.id, reply: "reject", directory, }) - return - } - if (res.outcome.optionId !== "reject" && permission.permission == "edit") { - const metadata = permission.metadata || {} - const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" - const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" - - const content = await Bun.file(filepath).text() - const newContent = getNewContent(content, diff) - - if (newContent) { - this.connection.writeTextFile({ - sessionId: sessionId, - path: filepath, - content: newContent, - }) - } - } - await this.config.sdk.permission.reply({ + return undefined + }) + + if (!res) return + if (res.outcome.outcome !== "selected") { + await this.sdk.permission.reply({ requestID: permission.id, - reply: res.outcome.optionId as "once" | "always" | "reject", + reply: "reject", directory, }) - } catch (err) { - log.error("unexpected error when handling permission", { error: err }) - } finally { - break + return } - case "message.part.updated": - log.info("message part updated", { event: event.properties }) - try { - const props = event.properties - const { part } = props - - const message = await this.config.sdk.session - .message( - { - sessionID: part.sessionID, - messageID: part.messageID, - directory, + if (res.outcome.optionId !== "reject" && permission.permission == "edit") { + const metadata = permission.metadata || {} + const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" + const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" + + const content = await Bun.file(filepath).text() + const newContent = getNewContent(content, diff) + + if (newContent) { + this.connection.writeTextFile({ + sessionId: session.id, + path: filepath, + content: newContent, + }) + } + } + + await this.sdk.permission.reply({ + requestID: permission.id, + reply: res.outcome.optionId as "once" | "always" | "reject", + directory, + }) + }) + .catch((error) => { + log.error("failed to handle permission", { error, permissionID: permission.id }) + }) + .finally(() => { + if (this.permissionQueues.get(permission.sessionID) === next) { + this.permissionQueues.delete(permission.sessionID) + } + }) + this.permissionQueues.set(permission.sessionID, next) + return + } + + case "message.part.updated": { + log.info("message part updated", { event: event.properties }) + const props = event.properties + const part = props.part + const session = this.sessionManager.tryGet(part.sessionID) + if (!session) return + const sessionId = session.id + const directory = session.cwd + + const message = await this.sdk.session + .message( + { + sessionID: part.sessionID, + messageID: part.messageID, + directory, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((error) => { + log.error("unexpected error when fetching message", { error }) + return undefined + }) + + if (!message || message.info.role !== "assistant") return + + if (part.type === "tool") { + switch (part.state.status) { + case "pending": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: part.callID, + title: part.tool, + kind: toToolKind(part.tool), + status: "pending", + locations: [], + rawInput: {}, }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined }) + .catch((error) => { + log.error("failed to send tool pending to ACP", { error }) + }) + return - if (!message || message.info.role !== "assistant") return - - if (part.type === "tool") { - switch (part.state.status) { - case "pending": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: part.callID, - title: part.tool, - kind: toToolKind(part.tool), - status: "pending", - locations: [], - rawInput: {}, - }, - }) - .catch((err) => { - log.error("failed to send tool pending to ACP", { error: err }) - }) - break - case "running": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - }, - }) - .catch((err) => { - log.error("failed to send tool in_progress to ACP", { error: err }) - }) - break - case "completed": - const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } - - if (part.tool === "todowrite") { - const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) - if (parsedTodos.success) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "plan", - entries: parsedTodos.data.map((todo) => { - const status: PlanEntry["status"] = - todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) - return { - priority: "medium", - status, - content: todo.content, - } - }), - }, - }) - .catch((err) => { - log.error("failed to send session update for todo", { error: err }) - }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.error }) - } - } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, - }, - }) - .catch((err) => { - log.error("failed to send tool completed to ACP", { error: err }) - }) - break - case "error": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - }, - }, - }) - .catch((err) => { - log.error("failed to send tool error to ACP", { error: err }) - }) - break - } - } else if (part.type === "text") { - const delta = props.delta - if (delta && part.synthetic !== true) { + case "running": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "in_progress", + kind: toToolKind(part.tool), + title: part.tool, + locations: toLocations(part.tool, part.state.input), + rawInput: part.state.input, + }, + }) + .catch((error) => { + log.error("failed to send tool in_progress to ACP", { error }) + }) + return + + case "completed": { + const kind = toToolKind(part.tool) + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: part.state.output, + }, + }, + ] + + if (kind === "edit") { + const input = part.state.input + const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" + const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" + const newText = + typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + content.push({ + type: "diff", + path: filePath, + oldText, + newText, + }) + } + + if (part.tool === "todowrite") { + const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) + if (parsedTodos.success) { await this.connection .sessionUpdate({ sessionId, update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: delta, - }, + sessionUpdate: "plan", + entries: parsedTodos.data.map((todo) => { + const status: PlanEntry["status"] = + todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) + return { + priority: "medium", + status, + content: todo.content, + } + }), }, }) - .catch((err) => { - log.error("failed to send text to ACP", { error: err }) + .catch((error) => { + log.error("failed to send session update for todo", { error }) }) + } else { + log.error("failed to parse todo output", { error: parsedTodos.error }) } - } else if (part.type === "reasoning") { - const delta = props.delta - if (delta) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", + } + + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "completed", + kind, + content, + title: part.state.title, + rawInput: part.state.input, + rawOutput: { + output: part.state.output, + metadata: part.state.metadata, + }, + }, + }) + .catch((error) => { + log.error("failed to send tool completed to ACP", { error }) + }) + return + } + case "error": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "failed", + kind: toToolKind(part.tool), + title: part.tool, + rawInput: part.state.input, + content: [ + { + type: "content", content: { type: "text", - text: delta, + text: part.state.error, }, }, - }) - .catch((err) => { - log.error("failed to send reasoning to ACP", { error: err }) - }) - } - } - } finally { - break - } + ], + rawOutput: { + error: part.state.error, + }, + }, + }) + .catch((error) => { + log.error("failed to send tool error to ACP", { error }) + }) + return + } + } + + if (part.type === "text") { + const delta = props.delta + if (delta && part.ignored !== true) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: delta, + }, + }, + }) + .catch((error) => { + log.error("failed to send text to ACP", { error }) + }) + } + return } + + if (part.type === "reasoning") { + const delta = props.delta + if (delta) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: delta, + }, + }, + }) + .catch((error) => { + log.error("failed to send reasoning to ACP", { error }) + }) + } + } + return } - }) + } } async initialize(params: InitializeRequest): Promise { @@ -409,8 +459,6 @@ export namespace ACP { sessionId, }) - this.setupEventSubscriptions(state) - return { sessionId, models: load.models, @@ -436,18 +484,16 @@ export namespace ACP { const model = await defaultModel(this.config, directory) // Store ACP session state - const state = await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) + await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) - const mode = await this.loadSessionMode({ + const result = await this.loadSessionMode({ cwd: directory, mcpServers: params.mcpServers, sessionId, }) - this.setupEventSubscriptions(state) - // Replay session history const messages = await this.sdk.session .messages( @@ -463,12 +509,20 @@ export namespace ACP { return undefined }) + const lastUser = messages?.findLast((m) => m.info.role === "user")?.info + if (lastUser?.role === "user") { + result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` + if (result.modes.availableModes.some((m) => m.id === lastUser.agent)) { + result.modes.currentModeId = lastUser.agent + } + } + for (const msg of messages ?? []) { log.debug("replay message", msg) await this.processMessage(msg) } - return mode + return result } catch (e) { const error = MessageV2.fromError(e, { providerID: this.config.defaultModel?.providerID ?? "unknown", @@ -633,7 +687,7 @@ export namespace ACP { break } } else if (part.type === "text") { - if (part.text) { + if (part.text && !part.ignored) { await this.connection .sessionUpdate({ sessionId, @@ -649,6 +703,83 @@ export namespace ACP { log.error("failed to send text to ACP", { error: err }) }) } + } else if (part.type === "file") { + // Replay file attachments as appropriate ACP content blocks. + // OpenCode stores files internally as { type: "file", url, filename, mime }. + // We convert these back to ACP blocks based on the URL scheme and MIME type: + // - file:// URLs → resource_link + // - data: URLs with image/* → image block + // - data: URLs with text/* or application/json → resource with text + // - data: URLs with other types → resource with blob + const url = part.url + const filename = part.filename ?? "file" + const mime = part.mime || "application/octet-stream" + const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk" + + if (url.startsWith("file://")) { + // Local file reference - send as resource_link + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: messageChunk, + content: { type: "resource_link", uri: url, name: filename, mimeType: mime }, + }, + }) + .catch((err) => { + log.error("failed to send resource_link to ACP", { error: err }) + }) + } else if (url.startsWith("data:")) { + // Embedded content - parse data URL and send as appropriate block type + const base64Match = url.match(/^data:([^;]+);base64,(.*)$/) + const dataMime = base64Match?.[1] + const base64Data = base64Match?.[2] ?? "" + + const effectiveMime = dataMime || mime + + if (effectiveMime.startsWith("image/")) { + // Image - send as image block + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: messageChunk, + content: { + type: "image", + mimeType: effectiveMime, + data: base64Data, + uri: `file://${filename}`, + }, + }, + }) + .catch((err) => { + log.error("failed to send image to ACP", { error: err }) + }) + } else { + // Non-image: text types get decoded, binary types stay as blob + const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json" + const resource = isText + ? { + uri: `file://${filename}`, + mimeType: effectiveMime, + text: Buffer.from(base64Data, "base64").toString("utf-8"), + } + : { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data } + + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: messageChunk, + content: { type: "resource", resource }, + }, + }) + .catch((err) => { + log.error("failed to send resource to ACP", { error: err }) + }) + } + } + // URLs that don't match file:// or data: are skipped (unsupported) } else if (part.type === "reasoning") { if (part.text) { await this.connection @@ -847,39 +978,57 @@ export namespace ACP { text: part.text, }) break - case "image": + case "image": { + const parsed = parseUri(part.uri ?? "") + const filename = parsed.type === "file" ? parsed.filename : "image" if (part.data) { parts.push({ type: "file", url: `data:${part.mimeType};base64,${part.data}`, - filename: "image", + filename, mime: part.mimeType, }) } else if (part.uri && part.uri.startsWith("http:")) { parts.push({ type: "file", url: part.uri, - filename: "image", + filename, mime: part.mimeType, }) } break + } case "resource_link": const parsed = parseUri(part.uri) + // Use the name from resource_link if available + if (part.name && parsed.type === "file") { + parsed.filename = part.name + } parts.push(parsed) break - case "resource": + case "resource": { const resource = part.resource - if ("text" in resource) { + if ("text" in resource && resource.text) { parts.push({ type: "text", text: resource.text, }) + } else if ("blob" in resource && resource.blob && resource.mimeType) { + // Binary resource (PDFs, etc.): store as file part with data URL + const parsed = parseUri(resource.uri ?? "") + const filename = parsed.type === "file" ? parsed.filename : "file" + parts.push({ + type: "file", + url: `data:${resource.mimeType};base64,${resource.blob}`, + filename, + mime: resource.mimeType, + }) } break + } default: break diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 70b65834705..151fa5646ba 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -13,6 +13,10 @@ export class ACPSessionManager { this.sdk = sdk } + tryGet(sessionId: string): ACPSessionState | undefined { + return this.sessions.get(sessionId) + } + async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise { const session = await this.sdk.session .create( diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 0725933d731..2b44308f130 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,10 +1,12 @@ import { Config } from "../config/config" import z from "zod" import { Provider } from "../provider/provider" -import { generateObject, type ModelMessage } from "ai" +import { generateObject, streamObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" import { Truncate } from "../tool/truncation" +import { Auth } from "../auth" +import { ProviderTransform } from "../provider/transform" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" @@ -276,10 +278,12 @@ export namespace Agent { const defaultModel = input.model ?? (await Provider.defaultModel()) const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID) const language = await Provider.getLanguage(model) + const system = SystemPrompt.header(defaultModel.providerID) system.push(PROMPT_GENERATE) const existing = await list() - const result = await generateObject({ + + const params = { experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, metadata: { @@ -305,7 +309,24 @@ export namespace Agent { whenToUse: z.string(), systemPrompt: z.string(), }), - }) + } satisfies Parameters[0] + + if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") { + const result = streamObject({ + ...params, + providerOptions: ProviderTransform.providerOptions(model, { + instructions: SystemPrompt.instructions(), + store: false, + }), + onError: () => {}, + }) + for await (const part of result.fullStream) { + if (part.type === "error") throw part.error + } + return result.object + } + + const result = await generateObject(params) return result.object } } diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index ef6b0c4fc92..d1236ff40bc 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -70,8 +70,8 @@ export const AgentCommand = cmd({ }) async function getAvailableTools(agent: Agent.Info) { - const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID - return ToolRegistry.tools(providerID, agent) + const model = agent.model ?? (await Provider.defaultModel()) + return ToolRegistry.tools(model, agent) } async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 7105b022998..7c709a19348 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -291,6 +291,10 @@ function App() { keybind: "session_list", category: "Session", suggested: sync.data.session.length > 0, + slash: { + name: "sessions", + aliases: ["resume", "continue"], + }, onSelect: () => { dialog.replace(() => ) }, @@ -301,6 +305,10 @@ function App() { value: "session.new", keybind: "session_new", category: "Session", + slash: { + name: "new", + aliases: ["clear"], + }, onSelect: () => { const current = promptRef.current // Don't require focus - if there's any text, preserve it @@ -319,26 +327,29 @@ function App() { keybind: "model_list", suggested: true, category: "Agent", + slash: { + name: "models", + }, onSelect: () => { dialog.replace(() => ) }, }, { title: "Model cycle", - disabled: true, value: "model.cycle_recent", keybind: "model_cycle_recent", category: "Agent", + hidden: true, onSelect: () => { local.model.cycle(1) }, }, { title: "Model cycle reverse", - disabled: true, value: "model.cycle_recent_reverse", keybind: "model_cycle_recent_reverse", category: "Agent", + hidden: true, onSelect: () => { local.model.cycle(-1) }, @@ -348,6 +359,7 @@ function App() { value: "model.cycle_favorite", keybind: "model_cycle_favorite", category: "Agent", + hidden: true, onSelect: () => { local.model.cycleFavorite(1) }, @@ -357,6 +369,7 @@ function App() { value: "model.cycle_favorite_reverse", keybind: "model_cycle_favorite_reverse", category: "Agent", + hidden: true, onSelect: () => { local.model.cycleFavorite(-1) }, @@ -366,6 +379,9 @@ function App() { value: "agent.list", keybind: "agent_list", category: "Agent", + slash: { + name: "agents", + }, onSelect: () => { dialog.replace(() => ) }, @@ -374,6 +390,9 @@ function App() { title: "Toggle MCPs", value: "mcp.list", category: "Agent", + slash: { + name: "mcps", + }, onSelect: () => { dialog.replace(() => ) }, @@ -391,7 +410,7 @@ function App() { value: "agent.cycle", keybind: "agent_cycle", category: "Agent", - disabled: true, + hidden: true, onSelect: () => { local.agent.move(1) }, @@ -401,6 +420,7 @@ function App() { value: "variant.cycle", keybind: "variant_cycle", category: "Agent", + hidden: true, onSelect: () => { local.model.variant.cycle() }, @@ -410,7 +430,7 @@ function App() { value: "agent.cycle.reverse", keybind: "agent_cycle_reverse", category: "Agent", - disabled: true, + hidden: true, onSelect: () => { local.agent.move(-1) }, @@ -419,6 +439,9 @@ function App() { title: "Connect provider", value: "provider.connect", suggested: !connected(), + slash: { + name: "connect", + }, onSelect: () => { dialog.replace(() => ) }, @@ -428,6 +451,9 @@ function App() { title: "View status", keybind: "status_view", value: "opencode.status", + slash: { + name: "status", + }, onSelect: () => { dialog.replace(() => ) }, @@ -437,6 +463,9 @@ function App() { title: "Switch theme", value: "theme.switch", keybind: "theme_list", + slash: { + name: "themes", + }, onSelect: () => { dialog.replace(() => ) }, @@ -479,6 +508,9 @@ function App() { { title: "Help", value: "help.show", + slash: { + name: "help", + }, onSelect: () => { dialog.replace(() => ) }, @@ -505,6 +537,10 @@ function App() { { title: "Exit the app", value: "app.exit", + slash: { + name: "exit", + aliases: ["quit", "q"], + }, onSelect: () => exit(), category: "System", }, @@ -545,6 +581,7 @@ function App() { value: "terminal.suspend", keybind: "terminal_suspend", category: "System", + hidden: true, onSelect: () => { process.once("SIGCONT", () => { renderer.resume() diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index f7941baa2b1..4270f140174 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -61,7 +61,7 @@ function init() { trigger(name: string, source?: "prompt") { for (const option of options()) { if (option.value === name) { - option.onSelect?.(dialog, source) + option.onSelect?.(dialog) return } } @@ -83,6 +83,17 @@ function init() { get options() { return options() }, + slashes() { + return options() + .filter((o) => (o as CommandOption).value.startsWith("/")) + .map((o) => ({ + display: (o as any).display ?? (o as CommandOption).value, + value: (o as CommandOption).value, + description: (o as CommandOption).description, + aliases: (o as any).aliases, + onSelect: () => (o as CommandOption).onSelect?.(dialog), + })) as any + }, } return result } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 3d671f5178e..718929d445b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -5,7 +5,6 @@ import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Sh import { createStore } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { useSync } from "@tui/context/sync" -import { useLocal } from "@tui/context/local" import { useTheme, selectedForeground } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" import { useCommandDialog } from "@tui/component/dialog-command" @@ -74,11 +73,9 @@ export function Autocomplete(props: { fileStyleId: number agentStyleId: number promptPartTypeId: () => number - onUsage: (command: string) => void }) { const sdk = useSDK() const sync = useSync() - const local = useLocal() const command = useCommandDialog() const { theme } = useTheme() const dimensions = useTerminalDimensions() @@ -88,6 +85,7 @@ export function Autocomplete(props: { index: 0, selected: 0, visible: false as AutocompleteRef["visible"], + input: "keyboard" as "keyboard" | "mouse", }) const [positionTick, setPositionTick] = createSignal(0) @@ -131,6 +129,14 @@ export function Autocomplete(props: { return props.input().getTextRange(store.index + 1, props.input().cursorOffset) }) + // When the filter changes due to how TUI works, the mousemove might still be triggered + // via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so + // that the mouseover event doesn't trigger when filtering. + createEffect(() => { + filter() + setStore("input", "keyboard") + }) + function insertPart(text: string, part: PromptInfo["parts"][number]) { const input = props.input() const currentCursorOffset = input.cursorOffset @@ -335,20 +341,15 @@ export function Autocomplete(props: { ) }) - const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined)) const commands = createMemo((): AutocompleteOption[] => { - const results: AutocompleteOption[] = [] - const s = session() - - for (const command of sync.data.command) { - if (command.sessionOnly && !s) continue + const results: AutocompleteOption[] = [...command.slashes()] + for (const serverCommand of sync.data.command) { results.push({ - display: "/" + command.name + (command.mcp ? " (MCP)" : ""), - description: command.description, - aliases: command.aliases?.map((a) => "/" + a), + display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""), + description: serverCommand.description, onSelect: () => { - const newText = "/" + command.name + " " + const newText = "/" + serverCommand.name + " " const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().insertText(newText) @@ -356,148 +357,9 @@ export function Autocomplete(props: { }, }) } - if (s) { - results.push( - { - display: "/undo", - description: "undo the last message", - onSelect: () => { - command.trigger("session.undo") - }, - }, - { - display: "/redo", - description: "redo the last message", - onSelect: () => command.trigger("session.redo"), - }, - { - display: "/compact", - aliases: ["/summarize"], - description: "compact the session", - onSelect: () => command.trigger("session.compact"), - }, - { - display: "/unshare", - disabled: !s.share, - description: "unshare a session", - onSelect: () => command.trigger("session.unshare"), - }, - { - display: "/rename", - description: "rename session", - onSelect: () => command.trigger("session.rename"), - }, - { - display: "/copy", - description: "copy session transcript to clipboard", - onSelect: () => command.trigger("session.copy"), - }, - { - display: "/export", - description: "export session transcript to file", - onSelect: () => command.trigger("session.export"), - }, - { - display: "/timeline", - description: "jump to message", - onSelect: () => command.trigger("session.timeline"), - }, - { - display: "/fork", - description: "fork from message", - onSelect: () => command.trigger("session.fork"), - }, - { - display: "/thinking", - description: "toggle thinking visibility", - onSelect: () => command.trigger("session.toggle.thinking"), - }, - ) - if (sync.data.config.share !== "disabled") { - results.push({ - display: "/share", - disabled: !!s.share?.url, - description: "share a session", - onSelect: () => command.trigger("session.share"), - }) - } - } - results.push( - { - display: "/new", - aliases: ["/clear"], - description: "create a new session", - onSelect: () => command.trigger("session.new"), - }, - { - display: "/models", - description: "list models", - onSelect: () => command.trigger("model.list"), - }, - { - display: "/agents", - description: "list agents", - onSelect: () => command.trigger("agent.list"), - }, - { - display: "/session", - aliases: ["/resume", "/continue"], - description: "list sessions", - onSelect: () => command.trigger("session.list"), - }, - { - display: "/status", - description: "show status", - onSelect: () => command.trigger("opencode.status"), - }, - { - display: "/usage", - description: "show usage limits", - onSelect: () => props.onUsage("/usage"), - }, - { - display: "/mcp", - description: "toggle MCPs", - onSelect: () => command.trigger("mcp.list"), - }, - { - display: "/tools", - description: "list tools", - onSelect: () => command.trigger("tool.list"), - }, - { - display: "/theme", - description: "toggle theme", - onSelect: () => command.trigger("theme.switch"), - }, - { - display: "/editor", - description: "open editor", - onSelect: () => command.trigger("prompt.editor", "prompt"), - }, - { - display: "/connect", - description: "connect to a provider", - onSelect: () => command.trigger("provider.connect"), - }, - { - display: "/help", - description: "show help", - onSelect: () => command.trigger("help.show"), - }, - { - display: "/commands", - description: "show all commands", - onSelect: () => command.show(), - }, - { - display: "/exit", - aliases: ["/quit", "/q"], - description: "exit the app", - onSelect: () => command.trigger("app.exit"), - }, - ) + results.sort((a, b) => a.display.localeCompare(b.display)) + const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length if (!max) return results return results.map((item) => ({ @@ -511,9 +373,8 @@ export function Autocomplete(props: { const agentsValue = agents() const commandsValue = commands() - const mixed: AutocompleteOption[] = ( + const mixed: AutocompleteOption[] = store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] - ).filter((x) => x.disabled !== true) const currentFilter = filter() @@ -668,15 +529,18 @@ export function Autocomplete(props: { onKeyDown(e: KeyEvent) { if (store.visible) { const name = e.name?.toLowerCase() - const isNavUp = name === "up" - const isNavDown = name === "down" + const ctrlOnly = e.ctrl && !e.meta && !e.shift + const isNavUp = name === "up" || (ctrlOnly && name === "p") + const isNavDown = name === "down" || (ctrlOnly && name === "n") if (isNavUp) { + setStore("input", "keyboard") move(-1) e.preventDefault() return } if (isNavDown) { + setStore("input", "keyboard") move(1) e.preventDefault() return @@ -759,7 +623,17 @@ export function Autocomplete(props: { paddingRight={1} backgroundColor={index === store.selected ? theme.primary : undefined} flexDirection="row" - onMouseOver={() => moveTo(index)} + onMouseMove={() => { + setStore("input", "mouse") + }} + onMouseOver={() => { + if (store.input !== "mouse") return + moveTo(index) + }} + onMouseDown={() => { + setStore("input", "mouse") + moveTo(index) + }} onMouseUp={() => select()} > diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index a4e5719d262..c33407b5452 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -19,7 +19,6 @@ import { useRenderer } from "@opentui/solid" import { Editor } from "@tui/util/editor" import { useExit } from "../../context/exit" import { Clipboard } from "../../util/clipboard" -import { parseUriList } from "../../util/uri" import type { FilePart } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" @@ -31,7 +30,6 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" -import { DialogUsage, type UsageEntry } from "../dialog-usage" import { useTextareaKeybindings } from "../textarea-keybindings" export type PromptProps = { @@ -39,7 +37,6 @@ export type PromptProps = { visible?: boolean disabled?: boolean onSubmit?: () => void - onSearchToggle?: () => void ref?: (ref: PromptRef) => void hint?: JSX.Element showPlaceholder?: boolean @@ -62,20 +59,6 @@ export function Prompt(props: PromptProps) { let anchor: BoxRenderable let autocomplete: AutocompleteRef - // Paste coalescing: buffer rapid consecutive paste events (e.g., from MobaXterm - // which fragments large pastes into multiple bracketed paste sequences) - const pasteBuffer: { chunks: string[]; timer: Timer | null } = { - chunks: [], - timer: null, - } - const [isPasting, setIsPasting] = createSignal(false) - const PASTE_DEBOUNCE_MS = 100 - - // Cleanup paste timer on unmount - onCleanup(() => { - if (pasteBuffer.timer) clearTimeout(pasteBuffer.timer) - }) - const keybind = useKeybind() const local = useLocal() const sdk = useSDK() @@ -91,34 +74,6 @@ export function Prompt(props: PromptProps) { const { theme, syntax } = useTheme() const kv = useKV() - function handleUsageCommand(commandText: string) { - const parts = commandText.trim().split(/\s+/) - const provider = parts.length > 1 && !parts[1].startsWith("-") ? parts[1] : undefined - const refresh = parts.some((part) => part === "--refresh" || part === "-r") - - type UsageResponse = { - entries: UsageEntry[] - error?: string - } - - sdk.client.usage - .get({ provider, refresh }) - .then((res) => { - const data = res.data as UsageResponse | undefined - if (!data) return - if (data.entries.length > 0) { - dialog.replace(() => ) - return - } - const message = data.error ?? "No usage data available." - DialogAlert.show(dialog, "Usage", message) - }) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error) - DialogAlert.show(dialog, "Usage", message) - }) - } - function promptModelWarning() { toast.show({ variant: "warning", @@ -202,7 +157,7 @@ export function Prompt(props: PromptProps) { title: "Clear prompt", value: "prompt.clear", category: "Prompt", - disabled: true, + hidden: true, onSelect: (dialog) => { input.extmarks.clear() input.clear() @@ -212,9 +167,9 @@ export function Prompt(props: PromptProps) { { title: "Submit prompt", value: "prompt.submit", - disabled: true, keybind: "input_submit", category: "Prompt", + hidden: true, onSelect: (dialog) => { if (!input.focused) return submit() @@ -224,9 +179,9 @@ export function Prompt(props: PromptProps) { { title: "Paste", value: "prompt.paste", - disabled: true, keybind: "input_paste", category: "Prompt", + hidden: true, onSelect: async () => { const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { @@ -242,8 +197,9 @@ export function Prompt(props: PromptProps) { title: "Interrupt session", value: "session.interrupt", keybind: "session_interrupt", - disabled: status().type === "idle", category: "Session", + hidden: true, + enabled: status().type !== "idle", onSelect: (dialog) => { if (autocomplete.visible) return if (!input.focused) return @@ -274,7 +230,10 @@ export function Prompt(props: PromptProps) { category: "Session", keybind: "editor_open", value: "prompt.editor", - onSelect: async (dialog, trigger) => { + slash: { + name: "editor", + }, + onSelect: async (dialog) => { dialog.clear() // replace summarized text parts with the actual text @@ -287,7 +246,7 @@ export function Prompt(props: PromptProps) { const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text") - const value = trigger === "prompt" ? "" : text + const value = text const result = await Editor.open({ value, renderer }) if (!result.ok) return @@ -478,7 +437,7 @@ export function Prompt(props: PromptProps) { title: "Stash prompt", value: "prompt.stash", category: "Prompt", - disabled: !store.prompt.input, + enabled: !!store.prompt.input, onSelect: (dialog) => { if (!store.prompt.input) return stash.push({ @@ -496,7 +455,7 @@ export function Prompt(props: PromptProps) { title: "Stash pop", value: "prompt.stash.pop", category: "Prompt", - disabled: stash.list().length === 0, + enabled: stash.list().length > 0, onSelect: (dialog) => { const entry = stash.pop() if (entry) { @@ -512,7 +471,7 @@ export function Prompt(props: PromptProps) { title: "Stash list", value: "prompt.stash.list", category: "Prompt", - disabled: stash.list().length === 0, + enabled: stash.list().length > 0, onSelect: (dialog) => { dialog.replace(() => ( { - const command = inputText.split(" ")[0].slice(1) - return sync.data.command.some((x) => x.name === command) - }) - - if (isShell) { + if (store.mode === "shell") { sdk.client.session.shell({ sessionID, agent: local.agent.current().name, @@ -595,23 +544,15 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") - } - - if (isUsage) { - handleUsageCommand(inputText) - input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], + } else if ( + inputText.startsWith("/") && + iife(() => { + const command = inputText.split(" ")[0].slice(1) + console.log(command) + return sync.data.command.some((x) => x.name === command) }) - setStore("extmarkToPartIndex", new Map()) - props.onSubmit?.() - input.clear() - return - } - - if (isCommand) { - const [command, ...args] = inputText.split(" ") + ) { + let [command, ...args] = inputText.split(" ") sdk.client.session.command({ sessionID, command: command.slice(1), @@ -627,30 +568,29 @@ export function Prompt(props: PromptProps) { ...x, })), }) + } else { + sdk.client.session + .prompt({ + sessionID, + ...selectedModel, + messageID, + agent: local.agent.current().name, + model: selectedModel, + variant, + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: inputText, + }, + ...nonTextParts.map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), + ], + }) + .catch(() => {}) } - - if (!isShell && !isUsage && !isCommand) { - sdk.client.session.prompt({ - sessionID, - ...selectedModel, - messageID, - agent: local.agent.current().name, - model: selectedModel, - variant, - parts: [ - { - id: Identifier.ascending("part"), - type: "text", - text: inputText, - }, - ...nonTextParts.map((x) => ({ - id: Identifier.ascending("part"), - ...x, - })), - ], - }) - } - history.append({ ...store.prompt, mode: currentMode, @@ -675,22 +615,6 @@ export function Prompt(props: PromptProps) { } const exit = useExit() - let lastExitAttempt = 0 - - async function tryExit() { - const now = Date.now() - if (now - lastExitAttempt < 2000) { - await exit() - return - } - lastExitAttempt = now - toast.show({ - variant: "warning", - message: "Press again to exit", - duration: 2000, - }) - } - function pasteText(text: string, virtualText: string) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset @@ -768,103 +692,19 @@ export function Prompt(props: PromptProps) { return } - // Process a coalesced paste (called after debounce timer expires) - async function processCoalescedPaste(pastedContent: string) { - if (!pastedContent) { - command.trigger("prompt.paste") - return - } - - // Handle file:// URIs or text/uri-list (common for drag-and-drop on Linux) - if (pastedContent.includes("file://")) { - const paths = parseUriList(pastedContent) - if (paths.length > 0) { - let handled = false - for (const path of paths) { - try { - const file = Bun.file(path) - if (file.type.startsWith("image/")) { - const content = await file - .arrayBuffer() - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => {}) - if (content) { - await pasteImage({ - filename: file.name, - mime: file.type, - content, - }) - handled = true - continue - } - } - } catch {} - } - - if (handled) return - } - } - - // Check if pasted content is a file path - const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") - const isUrl = /^(https?):\/\//.test(filepath) - if (!isUrl) { - try { - const file = Bun.file(filepath) - // Handle SVG as raw text content, not as base64 image - if (file.type === "image/svg+xml") { - const content = await file.text().catch(() => {}) - if (content) { - pasteText(content, `[SVG: ${file.name ?? "image"}]`) - return - } - } - if (file.type.startsWith("image/")) { - const content = await file - .arrayBuffer() - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => {}) - if (content) { - await pasteImage({ - filename: file.name, - mime: file.type, - content, - }) - return - } - } - } catch {} - } - - const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 - if ((lineCount >= 3 || pastedContent.length > 150) && !sync.data.config.experimental?.disable_paste_summary) { - pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) - return - } - - // Insert the text directly for small pastes - input.insertText(pastedContent) - setTimeout(() => { - input.getLayoutNode().markDirty() - input.gotoBufferEnd() - renderer.requestRender() - }, 0) - } - const highlight = createMemo(() => { if (keybind.leader) return theme.border if (store.mode === "shell") return theme.primary return local.agent.color(local.agent.current().name) }) - const hasVariants = createMemo(() => local.model.variant.list().length > 0) const showVariant = createMemo(() => { - if (!hasVariants()) return false + const variants = local.model.variant.list() + if (variants.length === 0) return false const current = local.model.variant.current() return !!current }) - const spinnerColor = createMemo(() => local.agent.color(local.agent.current().name)) const spinnerDef = createMemo(() => { const color = local.agent.color(local.agent.current().name) return { @@ -872,12 +712,14 @@ export function Prompt(props: PromptProps) { color, style: "blocks", inactiveFactor: 0.6, + // enableFading: false, minAlpha: 0.3, }), color: createColors({ color, style: "blocks", inactiveFactor: 0.6, + // enableFading: false, minAlpha: 0.3, }), } @@ -904,7 +746,6 @@ export function Prompt(props: PromptProps) { fileStyleId={fileStyleId} agentStyleId={agentStyleId} promptPartTypeId={() => promptPartTypeId} - onUsage={handleUsageCommand} /> (anchor = r)} visible={props.visible !== false}> { - event.preventDefault() - if (props.disabled) return + onPaste={async (event: PasteEvent) => { + if (props.disabled) { + event.preventDefault() + return + } // Normalize line endings at the boundary // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste // Replace CRLF first, then any remaining CR const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const pastedContent = normalizedText.trim() + if (!pastedContent) { + command.trigger("prompt.paste") + return + } - // Buffer the paste content for coalescing - // Some terminals (e.g., MobaXterm) fragment large pastes into multiple - // bracketed paste sequences, which would otherwise trigger premature submit - // Don't trim individual chunks - preserve inter-fragment whitespace - pasteBuffer.chunks.push(normalizedText) - setIsPasting(true) - - // Reset the debounce timer - if (pasteBuffer.timer) clearTimeout(pasteBuffer.timer) - pasteBuffer.timer = setTimeout(async () => { - // Coalesce all chunks and process - const coalesced = pasteBuffer.chunks.join("").trim() - pasteBuffer.chunks = [] - pasteBuffer.timer = null + // trim ' from the beginning and end of the pasted content. just + // ' and nothing else + const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") + const isUrl = /^(https?):\/\//.test(filepath) + if (!isUrl) { try { - await processCoalescedPaste(coalesced) - } finally { - // Only clear isPasting if no new paste arrived during processing - if (!pasteBuffer.timer) setIsPasting(false) - } - }, PASTE_DEBOUNCE_MS) + const file = Bun.file(filepath) + // Handle SVG as raw text content, not as base64 image + if (file.type === "image/svg+xml") { + event.preventDefault() + const content = await file.text().catch(() => {}) + if (content) { + pasteText(content, `[SVG: ${file.name ?? "image"}]`) + return + } + } + if (file.type.startsWith("image/")) { + event.preventDefault() + const content = await file + .arrayBuffer() + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => {}) + if (content) { + await pasteImage({ + filename: file.name, + mime: file.type, + content, + }) + return + } + } + } catch {} + } + + const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 + if ( + (lineCount >= 3 || pastedContent.length > 150) && + !sync.data.config.experimental?.disable_paste_summary + ) { + event.preventDefault() + pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + return + } + + // Force layout update and render for the pasted content + setTimeout(() => { + input.getLayoutNode().markDirty() + renderer.requestRender() + }, 0) }} ref={(r: TextareaRenderable) => { input = r @@ -1194,9 +1070,11 @@ export function Prompt(props: PromptProps) { - - {keybind.print("variant_cycle")} variants - + 0}> + + {keybind.print("variant_cycle")} variants + + {keybind.print("agent_cycle")} agents diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index ceb052bad75..d5ea9984c1c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -113,8 +113,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const file = Bun.file(path.join(Global.Path.state, "model.json")) + const state = { + pending: false, + } function save() { + if (!modelStore.ready) { + state.pending = true + return + } + state.pending = false Bun.write( file, JSON.stringify({ @@ -135,6 +143,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .catch(() => {}) .finally(() => { setModelStore("ready", true) + if (state.pending) save() }) const args = useArgs() diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index b54b226f72b..403cf1e0b16 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -15,6 +15,8 @@ export const TuiEvent = { "session.compact", "session.page.up", "session.page.down", + "session.line.up", + "session.line.down", "session.half.page.up", "session.half.page.down", "session.first", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f654961477b..6217c1b1d0e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -27,7 +27,6 @@ import { RGBA, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" -import { SearchInput, type SearchInputRef } from "@tui/component/prompt/search" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" @@ -40,7 +39,7 @@ import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" import type { ListTool } from "@/tool/ls" import type { EditTool } from "@/tool/edit" -import type { PatchTool } from "@/tool/patch" +import type { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" @@ -65,44 +64,19 @@ import { Clipboard } from "../../util/clipboard" import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import { Editor } from "../../util/editor" -import { Footer } from "./footer.tsx" -import { extend } from "@opentui/solid" -import { GhosttyTerminalRenderable } from "ghostty-opentui/opentui" -import { ptyToText } from "ghostty-opentui" import stripAnsi from "strip-ansi" +import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" -import { DialogSubagent } from "./dialog-subagent.tsx" -import { - getSpinnerFrame as _getSpinnerFrame, - setSpinnerStyle, - setSpinnerInterval, - DEFAULT_SPINNER_KEY, - DEFAULT_SPINNER_INTERVAL_MS, -} from "../../util/spinners" import { Global } from "@/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" -// Re-export for backward compatibility -export { getSpinnerFrame } from "../../util/spinners" - -// Local alias -const getSpinnerFrame = _getSpinnerFrame - -declare module "@opentui/solid" { - interface OpenTUIComponents { - "ghostty-terminal": typeof GhosttyTerminalRenderable - } -} - addDefaultParsers(parsers.parsers) -extend({ "ghostty-terminal": GhosttyTerminalRenderable }) - class CustomSpeedScroll implements ScrollAcceleration { constructor(private speed: number) {} @@ -113,28 +87,15 @@ class CustomSpeedScroll implements ScrollAcceleration { reset(): void {} } -type BashOutputView = { - command: string - output: () => string -} - const context = createContext<{ width: number - height: number sessionID: string conceal: () => boolean showThinking: () => boolean showTimestamps: () => boolean - showTokens: () => boolean - usernameVisible: () => boolean showDetails: () => boolean diffWrapMode: () => "word" | "none" sync: ReturnType - searchQuery: () => string - currentMatchMessageID: () => string | undefined - contextLimit: () => number - bashOutput: () => BashOutputView | undefined - showBashOutput: (view: BashOutputView | undefined) => void }>() function use() { @@ -143,67 +104,6 @@ function use() { return ctx } -function HighlightedText(props: { text: string; messageID: string }) { - const ctx = use() - const { theme } = useTheme() - - const segments = createMemo(() => { - const query = ctx.searchQuery().toLowerCase() - const text = props.text - if (!query) return [{ text, highlight: false }] - - const result: { text: string; highlight: boolean; isCurrentMatch?: boolean }[] = [] - const lowerText = text.toLowerCase() - const currentMatchID = ctx.currentMatchMessageID() - let lastIndex = 0 - let matchIndex = 0 - - while (true) { - const idx = lowerText.indexOf(query, lastIndex) - if (idx === -1) break - - if (idx > lastIndex) { - result.push({ text: text.slice(lastIndex, idx), highlight: false }) - } - - const isCurrentMatch = props.messageID === currentMatchID && matchIndex === 0 - result.push({ - text: text.slice(idx, idx + query.length), - highlight: true, - isCurrentMatch, - }) - matchIndex++ - lastIndex = idx + query.length - } - - if (lastIndex < text.length) { - result.push({ text: text.slice(lastIndex), highlight: false }) - } - - return result - }) - - return ( - - - {(segment) => ( - {segment.text}}> - - {segment.text} - - - )} - - - ) -} - export function Session() { const route = useRouteData("session") const { navigate } = useRoute() @@ -237,103 +137,17 @@ export function Session() { }) const dimensions = useTerminalDimensions() - const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto") + const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "hide") const [sidebarOpen, setSidebarOpen] = createSignal(false) - - const hw = 1 - const min = 20 - const max = 80 - - function clamp(n: number) { - return Math.max(min, Math.min(max, n)) - } - - const [w, setW] = createSignal(clamp(kv.get("sidebar_width", 42))) const [conceal, setConceal] = createSignal(true) const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true) const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") - const showTimestamps = createMemo(() => timestamps() === "show") - const [showTokens, setShowTokens] = createSignal(kv.get("tokens", "hide") === "show") - const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true)) const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) - const [headerVisible, setHeaderVisible] = createSignal(kv.get("header_visible", true)) - const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true)) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) - // Initialize spinner style and interval from KV store - const savedSpinnerStyle = kv.get("spinner_style", DEFAULT_SPINNER_KEY) - if (savedSpinnerStyle) { - setSpinnerStyle(savedSpinnerStyle) - } - const savedSpinnerInterval = kv.get("spinner_interval", DEFAULT_SPINNER_INTERVAL_MS) - if (savedSpinnerInterval) { - setSpinnerInterval(savedSpinnerInterval) - } - - // Search state - const [searchMode, setSearchMode] = createSignal(false) - const [searchQuery, setSearchQuery] = createSignal("") - const [currentMatchIndex, setCurrentMatchIndex] = createSignal(0) - - // Bash output viewer state - const [bashOutput, setBashOutput] = createSignal(undefined) - - // Sidebar resize drag state - const [drag, setDrag] = createSignal(false) - const [sx, setSx] = createSignal(0) - const [sw, setSw] = createSignal(0) - const [hov, setHov] = createSignal(false) - - function save() { - kv.set("sidebar_width", w()) - } - - function down(x: number) { - setDrag(true) - setSx(x) - setSw(w()) - } - - function move(x: number) { - if (!drag()) return - setW(clamp(sw() + (sx() - x))) - } - - function up() { - if (!drag()) return - setDrag(false) - save() - } - - // Compute search matches from messages - const searchMatches = createMemo(() => { - const query = searchQuery().toLowerCase() - if (!query) return [] - - const matches: { messageID: string; index: number }[] = [] - const msgs = messages() - - for (const msg of msgs) { - const parts = sync.data.part[msg.id] ?? [] - for (const part of parts) { - if (part.type === "text" && !part.synthetic) { - const text = part.text.toLowerCase() - let startIndex = 0 - let idx: number - while ((idx = text.indexOf(query, startIndex)) !== -1) { - matches.push({ messageID: msg.id, index: idx }) - startIndex = idx + 1 - } - } - } - } - return matches - }) - - const tall = createMemo(() => dimensions().height > 40) const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { if (session()?.parentID) return false @@ -341,10 +155,8 @@ export function Session() { if (sidebar() === "auto" && wide()) return true return false }) - const sidebarOverlay = createMemo(() => sidebarVisible() && !wide()) - const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() && !sidebarOverlay() ? w() + hw : 0) - 4) - - createEffect(() => !sidebarVisible() && setDrag(false)) + const showTimestamps = createMemo(() => timestamps() === "show") + const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) const scrollAcceleration = createMemo(() => { const tui = sync.data.config.tui @@ -402,9 +214,7 @@ export function Session() { }) let scroll: ScrollBoxRenderable - let bashScroll: ScrollBoxRenderable let prompt: PromptRef - let searchRef: SearchInputRef const keybind = useKeybind() // Allow exit when in child session (prompt is hidden) @@ -462,42 +272,6 @@ export function Session() { dialog.clear() } - const renderer = useRenderer() - - useKeyboard((evt) => { - if (dialog.stack.length > 0) return - - // Handle bash output viewer keyboard navigation - if (bashOutput()) { - const scroll = bashScroll - const amount = 3 - const pageAmount = Math.max(1, dimensions().height - 4) - if (evt.name === "escape" || (evt.name === "c" && evt.ctrl)) { - setBashOutput(undefined) - evt.preventDefault() - } else if (evt.name === "up") { - scroll?.scrollBy(-amount) - evt.preventDefault() - } else if (evt.name === "down") { - scroll?.scrollBy(amount) - evt.preventDefault() - } else if (evt.name === "pageup") { - scroll?.scrollBy(-pageAmount) - evt.preventDefault() - } else if (evt.name === "pagedown") { - scroll?.scrollBy(pageAmount) - evt.preventDefault() - } else if (evt.name === "home") { - scroll?.scrollTo(0) - evt.preventDefault() - } else if (evt.name === "end") { - scroll?.scrollTo(scroll.scrollHeight) - evt.preventDefault() - } - return - } - }) - function toBottom() { setTimeout(() => { if (scroll) scroll.scrollTo(scroll.scrollHeight) @@ -506,13 +280,6 @@ export function Session() { const local = useLocal() - const contextLimit = createMemo(() => { - const c = local.model.current() - if (!c) return 200000 - const provider = sync.data.provider.find((p) => p.id === c.providerID) - return provider?.models[c.modelID]?.limit.context ?? 200000 - }) - function moveChild(direction: number) { if (children().length === 1) return let next = children().findIndex((x) => x.id === session()?.id) + direction @@ -526,88 +293,30 @@ export function Session() { } } - function goToParent() { - const parentID = session()?.parentID - if (parentID) { - navigate({ type: "session", sessionID: parentID }) - } - } - - // Search navigation functions - function scrollToMatch(index: number) { - const matches = searchMatches() - if (matches.length === 0 || index < 0 || index >= matches.length) return - - const match = matches[index] - // Use setTimeout to ensure DOM is updated before scrolling - setTimeout(() => { - const child = scroll.getChildren().find((c) => c.id === match.messageID) - if (child) { - scroll.scrollBy(child.y - scroll.y - 1) - } - }, 0) - } - - function nextMatch() { - const matches = searchMatches() - if (matches.length === 0) return - - const nextIndex = (currentMatchIndex() + 1) % matches.length - setCurrentMatchIndex(nextIndex) - scrollToMatch(nextIndex) - } - - function previousMatch() { - const matches = searchMatches() - if (matches.length === 0) return - - const prevIndex = (currentMatchIndex() - 1 + matches.length) % matches.length - setCurrentMatchIndex(prevIndex) - scrollToMatch(prevIndex) - } - - function exitSearch() { - setSearchMode(false) - setSearchQuery("") - setCurrentMatchIndex(0) - prompt?.focus() - } - const command = useCommandDialog() command.register(() => [ - ...(sync.data.config.share !== "disabled" - ? [ - { - title: "Share session", - value: "session.share", - suggested: route.type === "session", - keybind: "session_share" as const, - disabled: !!session()?.share?.url, - category: "Session", - onSelect: async (dialog: any) => { - dialog.clear() - try { - const res = await sdk.client.session.share({ - sessionID: route.sessionID, - }) - if (res.data?.share?.url) { - await Clipboard.copy(res.data.share.url).catch(() => {}) - toast.show({ message: "Share URL copied to clipboard!", variant: "success" }) - } - } catch { - toast.show({ message: "Failed to share session", variant: "error" }) - } - }, - }, - ] - : []), { - title: "Search in messages", - value: "session.search", - keybind: "session_search", + title: "Share session", + value: "session.share", + suggested: route.type === "session", + keybind: "session_share", category: "Session", - onSelect: (dialog) => { - setSearchMode(true) + enabled: sync.data.config.share !== "disabled" && !session()?.share?.url, + slash: { + name: "share", + }, + onSelect: async (dialog) => { + await sdk.client.session + .share({ + sessionID: route.sessionID, + }) + .then((res) => + Clipboard.copy(res.data!.share!.url).catch(() => + toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }), + ), + ) + .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) + .catch(() => toast.show({ message: "Failed to share session", variant: "error" })) dialog.clear() }, }, @@ -616,6 +325,9 @@ export function Session() { value: "session.rename", keybind: "session_rename", category: "Session", + slash: { + name: "rename", + }, onSelect: (dialog) => { dialog.replace(() => ) }, @@ -625,6 +337,9 @@ export function Session() { value: "session.timeline", keybind: "session_timeline", category: "Session", + slash: { + name: "timeline", + }, onSelect: (dialog) => { dialog.replace(() => ( { dialog.replace(() => ( { const selectedModel = local.model.current() if (!selectedModel) { @@ -686,16 +408,19 @@ export function Session() { title: "Unshare session", value: "session.unshare", keybind: "session_unshare", - disabled: !session()?.share?.url, category: "Session", + enabled: !!session()?.share?.url, + slash: { + name: "unshare", + }, onSelect: async (dialog) => { - dialog.clear() await sdk.client.session .unshare({ sessionID: route.sessionID, }) - .then(() => toast.show({ message: "Session unshared", variant: "success" })) + .then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) .catch(() => toast.show({ message: "Failed to unshare session", variant: "error" })) + dialog.clear() }, }, { @@ -703,6 +428,9 @@ export function Session() { value: "session.undo", keybind: "messages_undo", category: "Session", + slash: { + name: "undo", + }, onSelect: async (dialog) => { const status = sync.data.session_status?.[route.sessionID] if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) @@ -737,8 +465,11 @@ export function Session() { title: "Redo", value: "session.redo", keybind: "messages_redo", - disabled: !session()?.revert?.messageID, category: "Session", + enabled: !!session()?.revert?.messageID, + slash: { + name: "redo", + }, onSelect: (dialog) => { dialog.clear() const messageID = session()?.revert?.messageID @@ -785,6 +516,10 @@ export function Session() { title: showTimestamps() ? "Hide timestamps" : "Show timestamps", value: "session.toggle.timestamps", category: "Session", + slash: { + name: "timestamps", + aliases: ["toggle-timestamps"], + }, onSelect: (dialog) => { setTimestamps((prev) => (prev === "show" ? "hide" : "show")) dialog.clear() @@ -794,21 +529,12 @@ export function Session() { title: showThinking() ? "Hide thinking" : "Show thinking", value: "session.toggle.thinking", category: "Session", - onSelect: (dialog) => { - setShowThinking((prev) => !prev) - dialog.clear() + slash: { + name: "thinking", + aliases: ["toggle-thinking"], }, - }, - { - title: showTokens() ? "Hide tokens" : "Show tokens", - value: "session.toggle.tokens", - category: "Session", onSelect: (dialog) => { - setShowTokens((prev) => { - const next = !prev - kv.set("tokens", next ? "show" : "hide") - return next - }) + setShowThinking((prev) => !prev) dialog.clear() }, }, @@ -816,6 +542,9 @@ export function Session() { title: "Toggle diff wrapping", value: "session.toggle.diffwrap", category: "Session", + slash: { + name: "diffwrap", + }, onSelect: (dialog) => { setDiffWrapMode((prev) => (prev === "word" ? "none" : "word")) dialog.clear() @@ -842,60 +571,55 @@ export function Session() { }, }, { - title: headerVisible() ? "Hide session header" : "Show session header", - value: "session.header.toggle", - keybind: "header_toggle", + title: animationsEnabled() ? "Disable animations" : "Enable animations", + value: "session.toggle.animations", category: "Session", onSelect: (dialog) => { - setHeaderVisible((prev) => { - const next = !prev - kv.set("header_visible", next) - return next - }) + setAnimationsEnabled((prev) => !prev) dialog.clear() }, }, { - title: userMessageMarkdown() ? "Disable user message markdown" : "Enable user message markdown", - value: "session.toggle.user_message_markdown", + title: "Page up", + value: "session.page.up", + keybind: "messages_page_up", category: "Session", + hidden: true, onSelect: (dialog) => { - setUserMessageMarkdown((prev) => { - const next = !prev - kv.set("user_message_markdown", next) - return next - }) + scroll.scrollBy(-scroll.height / 2) dialog.clear() }, }, { - title: animationsEnabled() ? "Disable animations" : "Enable animations", - value: "session.toggle.animations", + title: "Page down", + value: "session.page.down", + keybind: "messages_page_down", category: "Session", + hidden: true, onSelect: (dialog) => { - setAnimationsEnabled((prev) => !prev) + scroll.scrollBy(scroll.height / 2) dialog.clear() }, }, { - title: "Page up", - value: "session.page.up", - keybind: "messages_page_up", + title: "Line up", + value: "session.line.up", + keybind: "messages_line_up", category: "Session", disabled: true, onSelect: (dialog) => { - scroll.scrollBy(-scroll.height / 2) + scroll.scrollBy(-1) dialog.clear() }, }, { - title: "Page down", - value: "session.page.down", - keybind: "messages_page_down", + title: "Line down", + value: "session.line.down", + keybind: "messages_line_down", category: "Session", disabled: true, onSelect: (dialog) => { - scroll.scrollBy(scroll.height / 2) + scroll.scrollBy(1) dialog.clear() }, }, @@ -904,7 +628,7 @@ export function Session() { value: "session.half.page.up", keybind: "messages_half_page_up", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(-scroll.height / 4) dialog.clear() @@ -915,7 +639,7 @@ export function Session() { value: "session.half.page.down", keybind: "messages_half_page_down", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 4) dialog.clear() @@ -926,7 +650,7 @@ export function Session() { value: "session.first", keybind: "messages_first", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollTo(0) dialog.clear() @@ -937,7 +661,7 @@ export function Session() { value: "session.last", keybind: "messages_last", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollTo(scroll.scrollHeight) dialog.clear() @@ -948,6 +672,7 @@ export function Session() { value: "session.messages_last_user", keybind: "messages_last_user", category: "Session", + hidden: true, onSelect: () => { const messages = sync.data.message[route.sessionID] if (!messages || !messages.length) return @@ -974,6 +699,22 @@ export function Session() { } }, }, + { + title: "Next message", + value: "session.message.next", + keybind: "messages_next", + category: "Session", + hidden: true, + onSelect: (dialog) => scrollToMessage("next", dialog), + }, + { + title: "Previous message", + value: "session.message.previous", + keybind: "messages_previous", + category: "Session", + hidden: true, + onSelect: (dialog) => scrollToMessage("prev", dialog), + }, { title: "Copy last assistant message", value: "messages.copy", @@ -1020,8 +761,10 @@ export function Session() { { title: "Copy session transcript", value: "session.copy", - keybind: "session_copy", category: "Session", + slash: { + name: "copy", + }, onSelect: async (dialog) => { try { const sessionData = session() @@ -1049,6 +792,9 @@ export function Session() { value: "session.export", keybind: "session_export", category: "Session", + slash: { + name: "export", + }, onSelect: async (dialog) => { try { const sessionData = session() @@ -1091,7 +837,6 @@ export function Session() { // Open with EDITOR if available const result = await Editor.open({ value: transcript, renderer }) if (result.ok) { - // User edited the file, save the changes await Bun.write(filepath, result.content) } @@ -1108,7 +853,7 @@ export function Session() { value: "session.child.next", keybind: "session_child_cycle", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { moveChild(1) dialog.clear() @@ -1119,7 +864,7 @@ export function Session() { value: "session.child.previous", keybind: "session_child_cycle_reverse", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { moveChild(-1) dialog.clear() @@ -1130,9 +875,15 @@ export function Session() { value: "session.parent", keybind: "session_parent", category: "Session", - disabled: !session()?.parentID, + hidden: true, onSelect: (dialog) => { - goToParent() + const parentID = session()?.parentID + if (parentID) { + navigate({ + type: "session", + sessionID: parentID, + }) + } dialog.clear() }, }, @@ -1186,307 +937,192 @@ export function Session() { }) const dialog = useDialog() + const renderer = useRenderer() // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) - const { syntax } = useTheme() - return ( { - const matches = searchMatches() - const idx = currentMatchIndex() - return matches[idx]?.messageID - }, - bashOutput, - showBashOutput: setBashOutput, }} > - { - move(e.x) - }} - onMouseUp={() => { - up() - }} - onMouseDragEnd={() => { - up() - }} - > + - - {getSpinnerFrame()} Loading session... - - } - > - + +
- - - {(view) => ( - - - $ {view().command} - - (bashScroll = r)} - flexGrow={1} - paddingLeft={1} - paddingBottom={1} - scrollAcceleration={scrollAcceleration()} - > - - - - - - ESC to close | ↑/↓ scroll | PgUp/PgDn page | Home/End top/bottom - - - - - )} - - - <> - (scroll = r)} - verticalScrollbarOptions={{ - paddingLeft: 1, - visible: showScrollbar(), - trackOptions: { - backgroundColor: theme.backgroundElement, - foregroundColor: theme.border, - }, - }} - stickyScroll={true} - stickyStart="bottom" - flexGrow={1} - scrollAcceleration={scrollAcceleration()} - > - - {(message, index) => ( - - - - {(function () { - const command = useCommandDialog() - const [hover, setHover] = createSignal(false) - const dialog = useDialog() - - const handleUnrevert = async () => { - const confirmed = await DialogConfirm.show( - dialog, - "Confirm Redo", - "Are you sure you want to restore the reverted messages?", - ) - if (confirmed) { - command.trigger("session.redo") - } - } - - return ( - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={handleUnrevert} - marginTop={1} - flexShrink={0} - border={["left"]} - customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.backgroundPanel} - > - - {revert()!.reverted.length} message reverted - - {keybind.print("messages_redo")} or - /redo to restore - - - - - {(file) => ( - - {file.filename} - 0}> - +{file.additions} - - 0}> - -{file.deletions} - - - )} - - - - - - ) - })()} - - = revert()!.messageID}> - <> - - - { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - prompt.set(promptInfo)} - /> - )) - }} - message={message as UserMessage} - parts={sync.data.part[message.id] ?? []} - pending={pending()} - userMessageMarkdown={userMessageMarkdown()} - syntax={syntax()} - /> - - - - - - - )} - - - - - 0}> - - - - (searchRef = r)} - sessionID={route.sessionID} - onInput={(query) => { - setSearchQuery(query) - setCurrentMatchIndex(0) - if (query && searchMatches().length > 0) { - scrollToMatch(0) - } - }} - onNext={nextMatch} - onPrevious={previousMatch} - onExit={exitSearch} - matchInfo={{ - current: currentMatchIndex(), - total: searchMatches().length, - }} - /> - - 0}> - - - - (scroll = r)} + viewportOptions={{ + paddingRight: showScrollbar() ? 1 : 0, + }} + verticalScrollbarOptions={{ + paddingLeft: 1, + visible: showScrollbar(), + trackOptions: { + backgroundColor: theme.backgroundElement, + foregroundColor: theme.border, + }, + }} + stickyScroll={true} + stickyStart="bottom" + flexGrow={1} + scrollAcceleration={scrollAcceleration()} + > + + {(message, index) => ( + + + {(function () { + const command = useCommandDialog() + const [hover, setHover] = createSignal(false) + const dialog = useDialog() + + const handleUnrevert = async () => { + const confirmed = await DialogConfirm.show( + dialog, + "Confirm Redo", + "Are you sure you want to restore the reverted messages?", + ) + if (confirmed) { + command.trigger("session.redo") } - ref={(r) => { - prompt = r - promptRef.set(r) - if (route.initialPrompt) { - r.set(route.initialPrompt) - } - }} - disabled={permissions().length > 0 || questions().length > 0} - onSubmit={() => { - toBottom() - }} - onSearchToggle={() => { - setSearchMode(true) - }} - sessionID={route.sessionID} - /> - - - - -