diff options
| author | David Hill <[email protected]> | 2025-12-16 10:54:51 +0000 |
|---|---|---|
| committer | David Hill <[email protected]> | 2025-12-16 10:54:51 +0000 |
| commit | 05e0759878cb0f24c981c69ae26f6be3ea5583c6 (patch) | |
| tree | 39aaf6b86a6f2b8e653120e80d8f0facb528051b | |
| parent | 2330ec6dc3000ae8b86810e9d59b414ad4f05f47 (diff) | |
| parent | 75e5130cf8f58b32ee3f3ba2249d5917e7e3d6fc (diff) | |
| download | opencode-05e0759878cb0f24c981c69ae26f6be3ea5583c6.tar.gz opencode-05e0759878cb0f24c981c69ae26f6be3ea5583c6.zip | |
Merge branch 'dev' of https://github.com/sst/opencode into dev
116 files changed, 3005 insertions, 1146 deletions
diff --git a/.github/workflows/auto-label-tui.yml b/.github/workflows/auto-label-tui.yml deleted file mode 100644 index c2f81a380..000000000 --- a/.github/workflows/auto-label-tui.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Auto-label TUI Issues - -on: - issues: - types: [opened] - -jobs: - auto-label: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: write - steps: - - name: Auto-label and assign issues - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const issue = context.payload.issue; - const title = issue.title; - const description = issue.body || ''; - - // Check for "opencode web" keyword - const webPattern = /(opencode web)/i; - const isWebRelated = webPattern.test(title) || webPattern.test(description); - - // Check for version patterns like v1.0.x or 1.0.x - const versionPattern = /[v]?1\.0\./i; - const isVersionRelated = versionPattern.test(title) || versionPattern.test(description); - - // Check for "nix" keyword - const nixPattern = /\bnix\b/i; - const isNixRelated = nixPattern.test(title) || nixPattern.test(description); - - const labels = []; - - if (isWebRelated) { - labels.push('web'); - - // Assign to adamdotdevin - await github.rest.issues.addAssignees({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - assignees: ['adamdotdevin'] - }); - } else if (isVersionRelated) { - // Only add opentui if NOT web-related - labels.push('opentui'); - } - - if (isNixRelated) { - labels.push('nix'); - } - - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: labels - }); - } diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9c44efe1b..ebfc5059b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ on: required: false type: string -concurrency: ${{ github.workflow }}-${{ github.ref }} +concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }} permissions: id-token: write @@ -109,6 +109,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 + ref: ${{ needs.publish.outputs.tagName }} - uses: apple-actions/import-codesign-certs@v2 if: ${{ runner.os == 'macOS' }} @@ -166,10 +167,15 @@ jobs: GH_TOKEN: ${{ github.token }} # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released - - run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage + - name: Install tauri-cli from portable appimage branch if: contains(matrix.settings.host, 'ubuntu') + run: | + cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force + echo "Installed tauri-cli version:" + cargo tauri --version - name: Build and upload artifacts + timeout-minutes: 20 uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml new file mode 100644 index 000000000..9cc76973d --- /dev/null +++ b/.github/workflows/triage.yml @@ -0,0 +1,34 @@ +name: Issue Triage + +on: + issues: + types: [opened] + +jobs: + triage: + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install opencode + run: curl -fsSL https://opencode.ai/install | bash + + - name: Triage issue + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + run: | + opencode run --agent triage "The following issue was just opened, triage it: + + Title: $ISSUE_TITLE + + $ISSUE_BODY" diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md new file mode 100644 index 000000000..6a020532f --- /dev/null +++ b/.opencode/agent/triage.md @@ -0,0 +1,12 @@ +--- +mode: primary +hidden: true +model: opencode/gpt-5-nano +tools: + "*": false + "github-triage": true +--- + +You are a triage agent responsible for triaging github issues. + +Use your github-triage tool to triage issues. diff --git a/.opencode/bun.lock b/.opencode/bun.lock new file mode 100644 index 000000000..f152a1646 --- /dev/null +++ b/.opencode/bun.lock @@ -0,0 +1,49 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "dependencies": { + "@octokit/rest": "^22.0.1", + "@opencode-ai/plugin": "0.0.0-dev-202512160508", + }, + }, + }, + "packages": { + "@octokit/auth-token": ["@octokit/[email protected]", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "@octokit/core": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + + "@octokit/endpoint": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ=="], + + "@octokit/graphql": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + + "@octokit/openapi-types": ["@octokit/[email protected]", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "@octokit/plugin-request-log": ["@octokit/[email protected]", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], + + "@octokit/request": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="], + + "@octokit/request-error": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "@octokit/rest": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], + + "@octokit/types": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@opencode-ai/plugin": ["@opencode-ai/[email protected]", "", { "dependencies": { "@opencode-ai/sdk": "0.0.0-dev-202512160508", "zod": "4.1.8" } }, "sha512-GLnvMQhEWRHG9E84FyyQKPKi54bGUkytXPfZYjwNy9W6djw8zAW/kpeYPrdIJHPdTHk4OjIHEwoB1SXZzGaLFQ=="], + + "@opencode-ai/sdk": ["@opencode-ai/[email protected]", "", {}, "sha512-ICpZ1bX528yQKqYGGyUJQMu3RY0F1pQ6RCoTJ4ESLiYmcXUY1EldgIidiwPA+A/zpEXLu2lPwPZ1LYn/bX6aFA=="], + + "before-after-hook": ["[email protected]", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + + "fast-content-type-parse": ["[email protected]", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + + "universal-user-agent": ["[email protected]", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + + "zod": ["[email protected]", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/.opencode/env.d.ts b/.opencode/env.d.ts new file mode 100644 index 000000000..f2b13a934 --- /dev/null +++ b/.opencode/env.d.ts @@ -0,0 +1,4 @@ +declare module "*.txt" { + const content: string + export default content +} diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index d5d97f4c9..cbcbb0c65 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -11,4 +11,7 @@ }, }, "mcp": {}, + "tools": { + "github-triage": false, + }, } diff --git a/.opencode/package.json b/.opencode/package.json new file mode 100644 index 000000000..88fd8e891 --- /dev/null +++ b/.opencode/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "@octokit/rest": "^22.0.1", + "@opencode-ai/plugin": "0.0.0-dev-202512160508" + } +} diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts new file mode 100644 index 000000000..f0437e623 --- /dev/null +++ b/.opencode/tool/github-triage.ts @@ -0,0 +1,51 @@ +import { Octokit } from "@octokit/rest" +import { tool } from "@opencode-ai/plugin" +import DESCRIPTION from "./github-triage.txt" + +function getIssueNumber(): number { + const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10) + if (!issue) throw new Error("ISSUE_NUMBER env var not set") + return issue +} + +export default tool({ + description: DESCRIPTION, + args: { + assignee: tool.schema + .enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"]) + .describe("The username of the assignee") + .default("rekram1-node"), + labels: tool.schema + .array(tool.schema.enum(["nix", "opentui", "perf", "web", "zen", "docs"])) + .describe("The labels(s) to add to the issue") + .optional(), + }, + async execute(args) { + const issue = getIssueNumber() + const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }) + const owner = "sst" + const repo = "opencode" + + const results: string[] = [] + + await octokit.rest.issues.addAssignees({ + owner, + repo, + issue_number: issue, + assignees: [args.assignee], + }) + results.push(`Assigned @${args.assignee} to issue #${issue}`) + + if (args.labels && args.labels.length > 0) { + await octokit.rest.issues.addLabels({ + owner, + repo, + issue_number: issue, + labels: args.labels, + }) + results.push(`Added labels: ${args.labels.join(", ")}`) + } + + return results.join("\n") + }, +}) diff --git a/.opencode/tool/github-triage.txt b/.opencode/tool/github-triage.txt new file mode 100644 index 000000000..14844a19f --- /dev/null +++ b/.opencode/tool/github-triage.txt @@ -0,0 +1,80 @@ +Use this tool to assign and/or label a Github issue. + +You can assign the following users: +- thdxr +- adamdotdevin +- fwang +- jayair +- kommander +- rekram1-node + + +You can use the following labels: +- nix +- opentui +- perf +- web +- zen +- docs + +Always try to assign an issue, if in doubt, assign rekram1-node to it. + +## Breakdown of responsibilities: + +### thdxr + +Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him. + +This relates to OpenCode server primarily but has overlap with just about anything + +### adamdotdevin + +Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him. + + +### fwang + +Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue. + +### jayair + +Jay is responsible for documentation. If there is an issue relating to documentation assign him. + +### kommander + +Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about: +- random characters on screen +- keybinds not working on different terminals +- general terminal stuff +Then assign the issue to Him. + +### rekram1-node + +Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things. +If no one else makes sense to assign, assign rekram1-node to it. + +## Breakdown of Labels: + +### nix + +Any issue that mentions nix, or nixos should have a nix label + +### opentui + +Anything relating to the TUI itself should have an opentui label + +### perf + +Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label + +### web + +Anything related to `opencode web` or the desktop app should have a web label. Never add this label for anything terminal/tui related + +### zen + +Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label + +### docs + +Anything related to the documentation should have a docs label @@ -20,7 +20,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -48,7 +48,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -75,7 +75,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -99,7 +99,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -123,7 +123,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -170,11 +170,11 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", - "@pierre/precision-diffs": "catalog:", + "@pierre/diffs": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", @@ -199,10 +199,10 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "@octokit/auth-app": "8.0.1", - "@octokit/rest": "22.0.0", + "@octokit/rest": "catalog:", "hono": "catalog:", "jose": "6.0.11", }, @@ -215,7 +215,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.153", + "version": "1.0.162", "bin": { "opencode": "./bin/opencode", }, @@ -238,7 +238,7 @@ "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.15.1", "@octokit/graphql": "9.0.2", - "@octokit/rest": "22.0.0", + "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", @@ -248,7 +248,7 @@ "@opentui/core": "0.0.0-20251211-4403a69a", "@opentui/solid": "0.0.0-20251211-4403a69a", "@parcel/watcher": "2.5.1", - "@pierre/precision-diffs": "catalog:", + "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", @@ -307,7 +307,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -327,7 +327,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.153", + "version": "1.0.162", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -338,7 +338,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -351,7 +351,7 @@ }, "packages/tauri": { "name": "@opencode-ai/tauri", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "@opencode-ai/desktop": "workspace:*", "@tauri-apps/api": "^2", @@ -376,12 +376,12 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", - "@pierre/precision-diffs": "catalog:", + "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", "@solid-primitives/resize-observer": "2.1.3", @@ -411,7 +411,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "zod": "catalog:", }, @@ -422,7 +422,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.153", + "version": "1.0.162", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -470,8 +470,9 @@ "@cloudflare/workers-types": "4.20251008.0", "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", + "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/precision-diffs": "0.6.1", + "@pierre/diffs": "1.0.0-beta.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", @@ -1286,7 +1287,7 @@ "@petamoriken/float16": ["@petamoriken/[email protected]", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="], - "@pierre/precision-diffs": ["@pierre/[email protected]", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-HXafRSOly6B0rRt6fuP0yy1MimHJMQ2NNnBGcIHhHwsgK4WWs+SBWRWt1usdgz0NIuSgXdIyQn8HY3F1jKyDBQ=="], + "@pierre/diffs": ["@pierre/[email protected]", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-W3dFWdFOBZ9OskGSOgN16aci8dsUyAavCxz3ZvbbVLTb2qRzMZ7H90qdfON13/N2l1HTyh84lkrCs1/sDvnRjQ=="], "@pkgjs/parseargs": ["@pkgjs/[email protected]", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -4112,11 +4113,13 @@ "@parcel/watcher/node-addon-api": ["[email protected]", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], - "@pierre/precision-diffs/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg=="], + "@pierre/diffs/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="], - "@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/types": "3.15.0" } }, "sha512-Hmwip5ovvSkg+Kc41JTvSHHVfCYF+C8Cp1omb5AJj4Xvd+y9IXz2rKJwmFRGsuN0vpHxywcXJ1+Y4B9S7EG1/A=="], + "@pierre/diffs/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ=="], - "@pierre/precision-diffs/shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="], + "@pierre/diffs/@shikijs/transformers": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/types": "3.19.0" } }, "sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw=="], + + "@pierre/diffs/shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/engine-oniguruma": "3.19.0", "@shikijs/langs": "3.19.0", "@shikijs/themes": "3.19.0", "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA=="], "@poppinss/dumper/supports-color": ["[email protected]", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], @@ -4670,19 +4673,19 @@ "@opentui/solid/@babel/core/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="], + "@pierre/diffs/@shikijs/core/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="], - "@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="], + "@pierre/diffs/@shikijs/engine-javascript/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="], - "@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg=="], + "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="], - "@pierre/precision-diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA=="], + "@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg=="], - "@pierre/precision-diffs/shiki/@shikijs/langs": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A=="], + "@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg=="], - "@pierre/precision-diffs/shiki/@shikijs/themes": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="], + "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A=="], - "@pierre/precision-diffs/shiki/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="], + "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="], "@slack/web-api/form-data/mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], diff --git a/flake.lock b/flake.lock index 58344d82c..580e0af73 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1765425892, - "narHash": "sha256-jlQpSkg2sK6IJVzTQBDyRxQZgKADC2HKMRfGCSgNMHo=", + "lastModified": 1765772535, + "narHash": "sha256-aq+dQoaPONOSjtFIBnAXseDm9TUhIbe215TPmkfMYww=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5d6bdbddb4695a62f0d00a3620b37a15275a5093", + "rev": "09b8fda8959d761445f12b55f380d90375a1d6bb", "type": "github" }, "original": { diff --git a/github/package.json b/github/package.json index 1a6598d6b..4d447716f 100644 --- a/github/package.json +++ b/github/package.json @@ -13,7 +13,7 @@ "@actions/core": "1.11.1", "@actions/github": "6.0.1", "@octokit/graphql": "9.0.1", - "@octokit/rest": "22.0.0", + "@octokit/rest": "catalog:", "@opencode-ai/sdk": "workspace:*" } } @@ -240,22 +240,23 @@ download_with_progress() { download_and_install() { print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version" - mkdir -p opencodetmp && cd opencodetmp + local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$" + mkdir -p "$tmp_dir" - if [[ "$os" == "windows" ]] || ! download_with_progress "$url" "$filename"; then - # Fallback to standard curl on Windows or if custom progress fails - curl -# -L -o "$filename" "$url" + if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then + # Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails + curl -# -L -o "$tmp_dir/$filename" "$url" fi if [ "$os" = "linux" ]; then - tar -xzf "$filename" + tar -xzf "$tmp_dir/$filename" -C "$tmp_dir" else - unzip -q "$filename" + unzip -q "$tmp_dir/$filename" -d "$tmp_dir" fi - mv opencode "$INSTALL_DIR" + mv "$tmp_dir/opencode" "$INSTALL_DIR" chmod 755 "${INSTALL_DIR}/opencode" - cd .. && rm -rf opencodetmp + rm -rf "$tmp_dir" } check_version diff --git a/nix/hashes.json b/nix/hashes.json index 5f3baf191..4db956bb3 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-lgPsYtNJT7a+mDk5cTiEJLlBnTMTjxZCl8bw5WxcuaM=" + "nodeModules": "sha256-PyoVOza+3WnwZbtpPF6uSN1zkyLsSG2VsgBfIMvIFAs=" } diff --git a/package.json b/package.json index ca2a10f78..2be8bfe76 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ ], "catalog": { "@types/bun": "1.3.4", + "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", "ulid": "3.0.1", "@kobalte/core": "0.13.11", @@ -30,7 +31,7 @@ "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/precision-diffs": "0.6.1", + "@pierre/diffs": "1.0.0-beta.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", "ai": "5.0.97", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 4fcaff701..d0f7aac82 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.153", + "version": "1.0.162", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/app/src/component/header.tsx b/packages/console/app/src/component/header.tsx index 39e833973..7bfcc7825 100644 --- a/packages/console/app/src/component/header.tsx +++ b/packages/console/app/src/component/header.tsx @@ -119,8 +119,8 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { <section data-component="top"> <div onContextMenu={handleLogoContextMenu}> <A href="/"> - <img data-slot="logo light" src={logoLight} alt="opencode logo light" /> - <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" /> + <img data-slot="logo light" src={logoLight} alt="opencode logo light" width="189" height="34" /> + <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" width="189" height="34" /> </A> </div> @@ -169,6 +169,25 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { </Match> </Switch> </li> + <Show when={!props.hideGetStarted}> + {" "} + <li> + {" "} + <A href="/download" data-slot="cta-button"> + {" "} + <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> + {" "} + <path + d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625" + stroke="currentColor" + stroke-width="1.5" + stroke-linecap="square" + />{" "} + </svg>{" "} + Free{" "} + </A>{" "} + </li> + </Show> </ul> </nav> <nav data-component="nav-mobile"> diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index e8a2ed252..29df86cbd 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -22,8 +22,8 @@ export const config = { // Static stats (used on landing page) stats: { - contributors: "375", - commits: "5,250", + contributors: "400", + commits: "5,000", monthlyUsers: "400,000", }, } as const diff --git a/packages/console/app/src/entry-server.tsx b/packages/console/app/src/entry-server.tsx index 913c8ca06..deaadc747 100644 --- a/packages/console/app/src/entry-server.tsx +++ b/packages/console/app/src/entry-server.tsx @@ -1,6 +1,8 @@ // @refresh reload import { createHandler, StartServer } from "@solidjs/start/server" +const criticalCSS = `[data-component="top"]{min-height:80px;display:flex;align-items:center}` + export default createHandler( () => ( <StartServer @@ -11,6 +13,7 @@ export default createHandler( <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta property="og:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" /> + <style>{criticalCSS}</style> {assets} </head> <body> diff --git a/packages/console/app/src/routes/index.css b/packages/console/app/src/routes/index.css index ae329b98b..f100acf8f 100644 --- a/packages/console/app/src/routes/index.css +++ b/packages/console/app/src/routes/index.css @@ -206,6 +206,7 @@ body { [data-component="top"] { padding: 24px var(--padding); height: 80px; + min-height: 80px; position: sticky; top: 0; display: flex; diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index 9948551e4..90bdfffdc 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -52,6 +52,21 @@ export default function Home() { <div data-component="content"> <section data-component="hero"> + <div data-component="desktop-app-banner"> + <span data-slot="badge">New</span> + <div data-slot="content"> + <span data-slot="text"> + Desktop app available in beta<span data-slot="platforms"> on macOS, Windows, and Linux</span>. + </span> + <a href="/download" data-slot="link"> + Download now + </a> + <a href="/download" data-slot="link-mobile"> + Download the desktop beta now + </a> + </div> + </div> + <div data-slot="hero-copy"> {/*<a data-slot="releases"*/} {/* href={release()?.url ?? `${config.github.repoUrl}/releases`}*/} @@ -213,7 +228,7 @@ export default function Home() { <span>[*]</span> <p> With over <strong>{config.github.starsFormatted.full}</strong> GitHub stars,{" "} - <strong>{config.stats.contributors}</strong> contributors, and almost{" "} + <strong>{config.stats.contributors}</strong> contributors, and over{" "} <strong>{config.stats.commits}</strong> commits, OpenCode is used and trusted by over{" "} <strong>{config.stats.monthlyUsers}</strong> developers every month. </p> diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f68f70436..02e3a6de3 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.0.153", + "version": "1.0.162", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 53a41670d..e80959975 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.153", + "version": "1.0.162", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index a03e3843b..2a244c47f 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.153", + "version": "1.0.162", "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 0f5afeaa9..fce5715c7 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/desktop", - "version": "1.0.153", + "version": "1.0.162", "description": "", "type": "module", "exports": { diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index a49dac9aa..789a8fa2d 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -9,9 +9,11 @@ import { Diff } from "@opencode-ai/ui/diff" import { GlobalSyncProvider } from "@/context/global-sync" import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" -import { SessionProvider } from "@/context/session" +import { TerminalProvider } from "@/context/terminal" +import { PromptProvider } from "@/context/prompt" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { CommandProvider } from "@/context/command" import Layout from "@/pages/layout" import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" @@ -34,16 +36,22 @@ const url = export function App() { return ( - <MarkedProvider> - <DiffComponentProvider component={Diff}> - <GlobalSDKProvider url={url}> - <GlobalSyncProvider> - <LayoutProvider> - <DialogProvider> + <DialogProvider> + <MarkedProvider> + <DiffComponentProvider component={Diff}> + <GlobalSDKProvider url={url}> + <GlobalSyncProvider> + <LayoutProvider> <NotificationProvider> <MetaProvider> <Font /> - <Router root={Layout}> + <Router + root={(props) => ( + <CommandProvider> + <Layout>{props.children}</Layout> + </CommandProvider> + )} + > <Route path="/" component={Home} /> <Route path="/:dir" component={DirectoryLayout}> <Route path="/" component={() => <Navigate href="session" />} /> @@ -51,9 +59,11 @@ export function App() { path="/session/:id?" component={(p) => ( <Show when={p.params.id || true} keyed> - <SessionProvider> - <Session /> - </SessionProvider> + <TerminalProvider> + <PromptProvider> + <Session /> + </PromptProvider> + </TerminalProvider> </Show> )} /> @@ -61,11 +71,11 @@ export function App() { </Router> </MetaProvider> </NotificationProvider> - </DialogProvider> - </LayoutProvider> - </GlobalSyncProvider> - </GlobalSDKProvider> - </DiffComponentProvider> - </MarkedProvider> + </LayoutProvider> + </GlobalSyncProvider> + </GlobalSDKProvider> + </DiffComponentProvider> + </MarkedProvider> + </DialogProvider> ) } diff --git a/packages/desktop/src/components/dialog-connect-provider.tsx b/packages/desktop/src/components/dialog-connect-provider.tsx index 4660e1398..0d6737815 100644 --- a/packages/desktop/src/components/dialog-connect-provider.tsx +++ b/packages/desktop/src/components/dialog-connect-provider.tsx @@ -108,20 +108,18 @@ export function DialogConnectProvider(props: { provider: string }) { async function complete() { await globalSDK.client.global.dispose() - setTimeout(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: `${provider().name} connected`, - description: `${provider().name} models are now available to use.`, - }) - dialog.replace(() => <DialogSelectModel provider={props.provider} />) - }, 1000) + dialog.close() + showToast({ + variant: "success", + icon: "circle-check", + title: `${provider().name} connected`, + description: `${provider().name} models are now available to use.`, + }) } function goBack() { if (methods().length === 1) { - dialog.replace(() => <DialogSelectProvider />) + dialog.show(() => <DialogSelectProvider />) return } if (store.authorization) { @@ -133,7 +131,7 @@ export function DialogConnectProvider(props: { provider: string }) { setStore("methodIndex", undefined) return } - dialog.replace(() => <DialogSelectProvider />) + dialog.show(() => <DialogSelectProvider />) } return ( @@ -352,7 +350,7 @@ export function DialogConnectProvider(props: { provider: string }) { }) if (result.error) { // TODO: show error - dialog.clear() + dialog.close() return } await complete() diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx index 0250963b0..61c518719 100644 --- a/packages/desktop/src/components/dialog-select-file.tsx +++ b/packages/desktop/src/components/dialog-select-file.tsx @@ -3,13 +3,18 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { FileIcon } from "@opencode-ai/ui/file-icon" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useSession } from "@/context/session" +import { useLayout } from "@/context/layout" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useParams } from "@solidjs/router" +import { createMemo } from "solid-js" export function DialogSelectFile() { - const session = useSession() + const layout = useLayout() const local = useLocal() const dialog = useDialog() + const params = useParams() + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) return ( <Dialog title="Select file"> <List @@ -20,9 +25,9 @@ export function DialogSelectFile() { key={(x) => x} onSelect={(path) => { if (path) { - session.layout.openTab("file://" + path) + tabs().open("file://" + path) } - dialog.clear() + dialog.close() }} > {(i) => ( diff --git a/packages/desktop/src/components/dialog-select-model-unpaid.tsx b/packages/desktop/src/components/dialog-select-model-unpaid.tsx index 7cdb24915..77e493d3c 100644 --- a/packages/desktop/src/components/dialog-select-model-unpaid.tsx +++ b/packages/desktop/src/components/dialog-select-model-unpaid.tsx @@ -42,7 +42,7 @@ export const DialogSelectModelUnpaid: Component = () => { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, }) - dialog.clear() + dialog.close() }} > {(i) => ( @@ -75,7 +75,7 @@ export const DialogSelectModelUnpaid: Component = () => { }} onSelect={(x) => { if (!x) return - dialog.replace(() => <DialogConnectProvider provider={x.id} />) + dialog.show(() => <DialogConnectProvider provider={x.id} />) }} > {(i) => ( @@ -105,7 +105,7 @@ export const DialogSelectModelUnpaid: Component = () => { class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium" icon="dot-grid" onClick={() => { - dialog.replace(() => <DialogSelectProvider />) + dialog.show(() => <DialogSelectProvider />) }} > View all providers diff --git a/packages/desktop/src/components/dialog-select-model.tsx b/packages/desktop/src/components/dialog-select-model.tsx index f0b2e6db9..622ab15fb 100644 --- a/packages/desktop/src/components/dialog-select-model.tsx +++ b/packages/desktop/src/components/dialog-select-model.tsx @@ -28,7 +28,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} - onClick={() => dialog.replace(() => <DialogSelectProvider />)} + onClick={() => dialog.show(() => <DialogSelectProvider />)} > Connect provider </Button> @@ -57,7 +57,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, }) - dialog.clear() + dialog.close() }} > {(i) => ( @@ -75,7 +75,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { <Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" - onClick={() => dialog.replace(() => <DialogManageModels />)} + onClick={() => dialog.show(() => <DialogManageModels />)} > Manage models </Button> diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx index 8da10b1d5..52fac7073 100644 --- a/packages/desktop/src/components/dialog-select-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -34,7 +34,7 @@ export const DialogSelectProvider: Component = () => { }} onSelect={(x) => { if (!x) return - dialog.replace(() => <DialogConnectProvider provider={x.id} />) + dialog.show(() => <DialogConnectProvider provider={x.id} />) }} > {(i) => ( diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 0c1be77db..6e147242d 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,12 +1,13 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" -import { createStore } from "solid-js/store" +import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" +import { createStore, produce } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt" +import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" -import { useNavigate } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" @@ -19,6 +20,10 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" +import { useCommand, formatKeybind } from "@/context/command" + +const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] interface PromptInputProps { class?: string @@ -53,26 +58,54 @@ const PLACEHOLDERS = [ "How do environment variables work here?", ] +interface SlashCommand { + id: string + trigger: string + title: string + description?: string + keybind?: string + type: "builtin" | "custom" +} + export const PromptInput: Component<PromptInputProps> = (props) => { const navigate = useNavigate() const sdk = useSDK() const sync = useSync() const local = useLocal() - const session = useSession() + const prompt = usePrompt() + const layout = useLayout() + const params = useParams() const dialog = useDialog() const providers = useProviders() + const command = useCommand() let editorRef!: HTMLDivElement + let fileInputRef!: HTMLInputElement + + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const status = createMemo( + () => + sync.data.session_status[params.id ?? ""] ?? { + type: "idle", + }, + ) + const working = createMemo(() => status()?.type !== "idle") const [store, setStore] = createStore<{ - popoverIsOpen: boolean + popover: "file" | "slash" | null historyIndex: number savedPrompt: Prompt | null placeholder: number + dragging: boolean + imageAttachments: ImageAttachmentPart[] }>({ - popoverIsOpen: false, + popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + dragging: false, + imageAttachments: [], }) const MAX_HISTORY = 100 @@ -88,58 +121,48 @@ export const PromptInput: Component<PromptInputProps> = (props) => { ) const clonePromptParts = (prompt: Prompt): Prompt => - prompt.map((part) => - part.type === "text" - ? { ...part } - : { - ...part, - selection: part.selection ? { ...part.selection } : undefined, - }, - ) + prompt.map((part) => { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + return { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + } + }) - const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) + const promptLength = (prompt: Prompt) => + prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) - const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => { - const length = position === "start" ? 0 : promptLength(prompt) - session.prompt.set(prompt, length) + const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { + const length = position === "start" ? 0 : promptLength(p) + prompt.set(p, length) requestAnimationFrame(() => { editorRef.focus() setCursorPosition(editorRef, length) }) } - const getCaretLineState = () => { + const getCaretState = () => { const selection = window.getSelection() - if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false } - const range = selection.getRangeAt(0) - const rect = range.getBoundingClientRect() - const editorRect = editorRef.getBoundingClientRect() - const style = window.getComputedStyle(editorRef) - const paddingTop = parseFloat(style.paddingTop) || 0 - const paddingBottom = parseFloat(style.paddingBottom) || 0 - let lineHeight = parseFloat(style.lineHeight) - if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16 - const scrollTop = editorRef.scrollTop - let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop - if (!Number.isFinite(relativeTop)) relativeTop = scrollTop - relativeTop = Math.max(0, relativeTop) - let caretHeight = rect.height - if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight - const relativeBottom = relativeTop + caretHeight - const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom) - const threshold = Math.max(2, lineHeight / 2) - + const textLength = promptLength(prompt.current()) + if (!selection || selection.rangeCount === 0) { + return { collapsed: false, cursorPosition: 0, textLength } + } + const anchorNode = selection.anchorNode + if (!anchorNode || !editorRef.contains(anchorNode)) { + return { collapsed: false, cursorPosition: 0, textLength } + } return { collapsed: selection.isCollapsed, - onFirstLine: relativeTop <= threshold, - onLastLine: contentHeight - relativeBottom <= threshold, + cursorPosition: getCursorPosition(editorRef), + textLength, } } createEffect(() => { - session.id + params.id editorRef.focus() - if (session.id) return + if (params.id) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length) }, 6500) @@ -148,14 +171,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const isFocused = createFocusSignal(() => editorRef) - const handlePaste = (event: ClipboardEvent) => { + const addImageAttachment = async (file: File) => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) return + + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result as string + const attachment: ImageAttachmentPart = { + type: "image", + id: crypto.randomUUID(), + filename: file.name, + mime: file.type, + dataUrl, + } + setStore( + produce((draft) => { + draft.imageAttachments.push(attachment) + }), + ) + } + reader.readAsDataURL(file) + } + + const removeImageAttachment = (id: string) => { + setStore( + produce((draft) => { + draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id) + }), + ) + } + + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) return + + const items = Array.from(clipboardData.items) + const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (imageItems.length > 0) { + event.preventDefault() + event.stopPropagation() + for (const item of imageItems) { + const file = item.getAsFile() + if (file) await addImageAttachment(file) + } + return + } + event.preventDefault() event.stopPropagation() - // @ts-expect-error - const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? "" + const plainText = clipboardData.getData("text/plain") ?? "" addPart({ type: "text", content: plainText, start: 0, end: 0 }) } + const handleDragOver = (event: DragEvent) => { + event.preventDefault() + const hasFiles = event.dataTransfer?.types.includes("Files") + if (hasFiles) { + setStore("dragging", true) + } + } + + const handleDragLeave = (event: DragEvent) => { + const related = event.relatedTarget as Node | null + const form = event.currentTarget as HTMLElement + if (!related || !form.contains(related)) { + setStore("dragging", false) + } + } + + const handleDrop = async (event: DragEvent) => { + event.preventDefault() + setStore("dragging", false) + + const files = event.dataTransfer?.files + if (!files) return + + for (const file of Array.from(files)) { + if (ACCEPTED_FILE_TYPES.includes(file.type)) { + await addImageAttachment(file) + } + } + } + onMount(() => { editorRef.addEventListener("paste", handlePaste) }) @@ -167,7 +265,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (isFocused()) { handleInput() } else { - setStore("popoverIsOpen", false) + setStore("popover", null) } }) @@ -182,9 +280,70 @@ export const PromptInput: Component<PromptInputProps> = (props) => { onSelect: handleFileSelect, }) + const slashCommands = createMemo<SlashCommand[]>(() => { + const builtin = command.options + .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) + .map((opt) => ({ + id: opt.id, + trigger: opt.slash!, + title: opt.title, + description: opt.description, + keybind: opt.keybind, + type: "builtin" as const, + })) + + const custom = sync.data.command.map((cmd) => ({ + id: `custom.${cmd.name}`, + trigger: cmd.name, + title: cmd.name, + description: cmd.description, + type: "custom" as const, + })) + + return [...custom, ...builtin] + }) + + const handleSlashSelect = (cmd: SlashCommand | undefined) => { + if (!cmd) return + setStore("popover", null) + + if (cmd.type === "custom") { + const text = `/${cmd.trigger} ` + editorRef.innerHTML = "" + editorRef.textContent = text + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const sel = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + sel?.removeAllRanges() + sel?.addRange(range) + }) + return + } + + editorRef.innerHTML = "" + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + command.trigger(cmd.id, "slash") + } + + const { + flat: slashFlat, + active: slashActive, + onInput: slashOnInput, + onKeyDown: slashOnKeyDown, + } = useFilteredList<SlashCommand>({ + items: slashCommands, + key: (x) => x?.id, + filterKeys: ["trigger", "title", "description"], + onSelect: handleSlashSelect, + }) + createEffect( on( - () => session.prompt.current(), + () => prompt.current(), (currentParts) => { const domParts = parseFromDOM() if (isPromptEqual(currentParts, domParts)) return @@ -253,14 +412,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const handleInput = () => { const rawParts = parseFromDOM() const cursorPosition = getCursorPosition(editorRef) - const rawText = rawParts.map((p) => p.content).join("") + const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) + const slashMatch = rawText.match(/^\/(\S*)$/) + if (atMatch) { onInput(atMatch[1]) - setStore("popoverIsOpen", true) - } else if (store.popoverIsOpen) { - setStore("popoverIsOpen", false) + setStore("popover", "file") + } else if (slashMatch) { + slashOnInput(slashMatch[1]) + setStore("popover", "slash") + } else { + setStore("popover", null) } if (store.historyIndex >= 0) { @@ -268,7 +432,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { setStore("savedPrompt", null) } - session.prompt.set(rawParts, cursorPosition) + prompt.set(rawParts, cursorPosition) } const addPart = (part: ContentPart) => { @@ -276,8 +440,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (!selection || selection.rangeCount === 0) return const cursorPosition = getCursorPosition(editorRef) - const prompt = session.prompt.current() - const rawText = prompt.map((p) => p.content).join("") + const currentPrompt = prompt.current() + const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -294,8 +458,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const range = selection.getRangeAt(0) if (atMatch) { - // let node: Node | null = range.startContainer - // let offset = range.startOffset let runningLength = 0 const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null) @@ -335,17 +497,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } handleInput() - setStore("popoverIsOpen", false) + setStore("popover", null) } const abort = () => sdk.client.session.abort({ - sessionID: session.id!, + sessionID: params.id!, }) const addToHistory = (prompt: Prompt) => { const text = prompt - .map((p) => p.content) + .map((p) => ("content" in p ? p.content : "")) .join("") .trim() if (!text) return @@ -353,7 +515,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const entry = clonePromptParts(prompt) const lastEntry = history.entries[0] if (lastEntry) { - const lastText = lastEntry.map((p) => p.content).join("") + const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("") if (lastText === text) return } @@ -367,7 +529,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (direction === "up") { if (entries.length === 0) return false if (current === -1) { - setStore("savedPrompt", clonePromptParts(session.prompt.current())) + setStore("savedPrompt", clonePromptParts(prompt.current())) setStore("historyIndex", 0) applyHistoryPrompt(entries[0], "start") return true @@ -403,24 +565,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { - if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { - onKeyDown(event) + if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { + if (store.popover === "file") { + onKeyDown(event) + } else { + slashOnKeyDown(event) + } event.preventDefault() return } if (event.key === "ArrowUp" || event.key === "ArrowDown") { - const { collapsed, onFirstLine, onLastLine } = getCaretLineState() + if (event.altKey || event.ctrlKey || event.metaKey) return + const { collapsed, cursorPosition, textLength } = getCaretState() if (!collapsed) return - const cursorPos = getCursorPosition(editorRef) - const textLength = promptLength(session.prompt.current()) const inHistory = store.historyIndex >= 0 - const isStart = cursorPos === 0 - const isEnd = cursorPos === textLength - const atAbsoluteStart = onFirstLine && isStart - const atAbsoluteEnd = onLastLine && isEnd - const allowUp = (inHistory && isEnd) || atAbsoluteStart - const allowDown = (inHistory && isStart) || atAbsoluteEnd + const atAbsoluteStart = cursorPosition === 0 + const atAbsoluteEnd = cursorPosition === textLength + const allowUp = (inHistory && atAbsoluteEnd) || atAbsoluteStart + const allowDown = (inHistory && atAbsoluteStart) || atAbsoluteEnd if (event.key === "ArrowUp") { if (!allowUp) return @@ -441,9 +604,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => { handleSubmit(event) } if (event.key === "Escape") { - if (store.popoverIsOpen) { - setStore("popoverIsOpen", false) - } else if (session.working()) { + if (store.popover) { + setStore("popover", null) + } else if (working()) { abort() } } @@ -451,18 +614,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() - const prompt = session.prompt.current() - const text = prompt.map((part) => part.content).join("") - if (text.trim().length === 0) { - if (session.working()) abort() + const currentPrompt = prompt.current() + const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") + const hasImageAttachments = store.imageAttachments.length > 0 + if (text.trim().length === 0 && !hasImageAttachments) { + if (working()) abort() return } - addToHistory(prompt) + addToHistory(currentPrompt) setStore("historyIndex", -1) setStore("savedPrompt", null) - let existing = session.info() + let existing = info() if (!existing) { const created = await sdk.client.session.create() existing = created.data ?? undefined @@ -470,32 +634,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } if (!existing) return - // if (!session.id) { - // session.layout.setOpenedTabs( - // session.layout.copyTabs("", session.id) - // } - const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) - const attachments = prompt.filter((part) => part.type === "file") - - // const activeFile = local.context.active() - // if (activeFile) { - // registerAttachment( - // activeFile.path, - // activeFile.selection, - // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection), - // ) - // } - - // for (const contextFile of local.context.all()) { - // registerAttachment( - // contextFile.path, - // contextFile.selection, - // formatAttachmentLabel(contextFile.path, contextFile.selection), - // ) - // } - - const attachmentParts = attachments.map((attachment) => { + const attachments = currentPrompt.filter( + (part) => part.type === "file", + ) as import("@/context/prompt").FileAttachmentPart[] + + const fileAttachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` @@ -517,11 +661,33 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } }) - session.layout.setActiveTab(undefined) - session.messages.setActive(undefined) - // Clear the editor DOM directly to ensure it's empty + const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })) + + tabs().setActive(undefined) editorRef.innerHTML = "" - session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + setStore("imageAttachments", []) + + if (text.startsWith("/")) { + const [cmdName, ...args] = text.split(" ") + const commandName = cmdName.slice(1) + const customCommand = sync.data.command.find((c) => c.name === commandName) + if (customCommand) { + sdk.client.session.command({ + sessionID: existing.id, + command: commandName, + arguments: args.join(" "), + agent: local.agent.current()!.name, + model: `${local.model.current()!.provider.id}/${local.model.current()!.id}`, + }) + return + } + } sdk.client.session.prompt({ sessionID: existing.id, @@ -535,55 +701,136 @@ export const PromptInput: Component<PromptInputProps> = (props) => { type: "text", text, }, - ...attachmentParts, + ...fileAttachmentParts, + ...imageAttachmentParts, ], }) } return ( <div class="relative size-full _max-h-[320px] flex flex-col gap-3"> - <Show when={store.popoverIsOpen}> + <Show when={store.popover}> <div - class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 - overflow-auto no-scrollbar flex flex-col p-2 pb-0 rounded-md + class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 + overflow-auto no-scrollbar flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" > - <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}> - <For each={flat()}> - {(i) => ( - <button - classList={{ - "w-full flex items-center justify-between rounded-md": true, - "bg-surface-raised-base-hover": active() === i, - }} - onClick={() => handleFileSelect(i)} - > - <div class="flex items-center gap-x-2 grow min-w-0"> - <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> - <div class="flex items-center text-14-regular"> - <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> - {getDirectory(i)} - </span> - <Show when={!i.endsWith("/")}> - <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> - </Show> - </div> - </div> - <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> - </button> - )} - </For> - </Show> + <Switch> + <Match when={store.popover === "file"}> + <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}> + <For each={flat()}> + {(i) => ( + <button + classList={{ + "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true, + "bg-surface-raised-base-hover": active() === i, + }} + onClick={() => handleFileSelect(i)} + > + <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> + <div class="flex items-center text-14-regular min-w-0"> + <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(i)}</span> + <Show when={!i.endsWith("/")}> + <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> + </Show> + </div> + </button> + )} + </For> + </Show> + </Match> + <Match when={store.popover === "slash"}> + <Show + when={slashFlat().length > 0} + fallback={<div class="text-text-weak px-2 py-1">No matching commands</div>} + > + <For each={slashFlat()}> + {(cmd) => ( + <button + classList={{ + "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true, + "bg-surface-raised-base-hover": slashActive() === cmd.id, + }} + onClick={() => handleSlashSelect(cmd)} + > + <div class="flex items-center gap-2 min-w-0"> + <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span> + <Show when={cmd.description}> + <span class="text-14-regular text-text-weak truncate">{cmd.description}</span> + </Show> + </div> + <div class="flex items-center gap-2 shrink-0"> + <Show when={cmd.type === "custom"}> + <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded"> + custom + </span> + </Show> + <Show when={cmd.keybind}> + <span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span> + </Show> + </div> + </button> + )} + </For> + </Show> + </Match> + </Switch> </div> </Show> <form onSubmit={handleSubmit} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} classList={{ - "bg-surface-raised-stronger-non-alpha shadow-xs-border": true, + "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, "rounded-md overflow-clip focus-within:shadow-xs-border": true, + "border-icon-info-active border-dashed": store.dragging, [props.class ?? ""]: !!props.class, }} > + <Show when={store.dragging}> + <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none"> + <div class="flex flex-col items-center gap-2 text-text-weak"> + <Icon name="photo" class="size-8" /> + <span class="text-14-regular">Drop images or PDFs here</span> + </div> + </div> + </Show> + <Show when={store.imageAttachments.length > 0}> + <div class="flex flex-wrap gap-2 px-3 pt-3"> + <For each={store.imageAttachments}> + {(attachment) => ( + <div class="relative group"> + <Show + when={attachment.mime.startsWith("image/")} + fallback={ + <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"> + <Icon name="folder" class="size-6 text-text-weak" /> + </div> + } + > + <img + src={attachment.dataUrl} + alt={attachment.filename} + class="size-16 rounded-md object-cover border border-border-base" + /> + </Show> + <button + type="button" + onClick={() => removeImageAttachment(attachment.id)} + class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" + > + <Icon name="close" class="size-3 text-text-weak" /> + </button> + <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"> + <span class="text-10-regular text-white truncate block">{attachment.filename}</span> + </div> + </div> + )} + </For> + </div> + </Show> <div class="relative max-h-[240px] overflow-y-auto"> <div ref={(el) => { @@ -598,7 +845,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - <Show when={!session.prompt.dirty()}> + <Show when={!prompt.dirty() && store.imageAttachments.length === 0}> <div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none"> Ask anything... "{PLACEHOLDERS[store.placeholder]}" </div> @@ -617,7 +864,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { as="div" variant="ghost" onClick={() => - dialog.push(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />)) + dialog.show(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />)) } > {local.model.current()?.name ?? "Select model"} @@ -625,34 +872,56 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <Icon name="chevron-down" size="small" /> </Button> </div> - <Tooltip - placement="top" - inactive={!session.prompt.dirty() && !session.working()} - value={ - <Switch> - <Match when={session.working()}> - <div class="flex items-center gap-2"> - <span>Stop</span> - <span class="text-icon-base text-12-medium text-[10px]!">ESC</span> - </div> - </Match> - <Match when={true}> - <div class="flex items-center gap-2"> - <span>Send</span> - <Icon name="enter" size="small" class="text-icon-base" /> - </div> - </Match> - </Switch> - } - > - <IconButton - type="submit" - disabled={!session.prompt.dirty() && !session.working()} - icon={session.working() ? "stop" : "arrow-up"} - variant="primary" - class="h-10 w-8 absolute right-2 bottom-2" + <div class="flex items-center gap-1 absolute right-2 bottom-2"> + <input + ref={fileInputRef} + type="file" + accept={ACCEPTED_IMAGE_TYPES.join(",")} + class="hidden" + onChange={(e) => { + const file = e.currentTarget.files?.[0] + if (file) addImageAttachment(file) + e.currentTarget.value = "" + }} /> - </Tooltip> + <Tooltip placement="top" value="Attach image"> + <IconButton + type="button" + icon="photo" + variant="ghost" + class="h-10 w-8" + onClick={() => fileInputRef.click()} + /> + </Tooltip> + <Tooltip + placement="top" + inactive={!prompt.dirty() && !working()} + value={ + <Switch> + <Match when={working()}> + <div class="flex items-center gap-2"> + <span>Stop</span> + <span class="text-icon-base text-12-medium text-[10px]!">ESC</span> + </div> + </Match> + <Match when={true}> + <div class="flex items-center gap-2"> + <span>Send</span> + <Icon name="enter" size="small" class="text-icon-base" /> + </div> + </Match> + </Switch> + } + > + <IconButton + type="submit" + disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()} + icon={working() ? "stop" : "arrow-up"} + variant="primary" + class="h-10 w-8" + /> + </Tooltip> + </div> </div> </form> </div> diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx index 865d9b30f..082525e28 100644 --- a/packages/desktop/src/components/terminal.tsx +++ b/packages/desktop/src/components/terminal.tsx @@ -2,7 +2,7 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" -import { LocalPTY } from "@/context/session" +import { LocalPTY } from "@/context/terminal" import { usePrefersDark } from "@solid-primitives/media" export interface TerminalProps extends ComponentProps<"div"> { diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx new file mode 100644 index 000000000..8fd76ee21 --- /dev/null +++ b/packages/desktop/src/context/command.tsx @@ -0,0 +1,239 @@ +import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" + +const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) + +export type KeybindConfig = string + +export interface Keybind { + key: string + ctrl: boolean + meta: boolean + shift: boolean + alt: boolean +} + +export interface CommandOption { + id: string + title: string + description?: string + category?: string + keybind?: KeybindConfig + slash?: string + suggested?: boolean + disabled?: boolean + onSelect?: (source?: "palette" | "keybind" | "slash") => void +} + +export function parseKeybind(config: string): Keybind[] { + if (!config || config === "none") return [] + + return config.split(",").map((combo) => { + const parts = combo.trim().toLowerCase().split("+") + const keybind: Keybind = { + key: "", + ctrl: false, + meta: false, + shift: false, + alt: false, + } + + for (const part of parts) { + switch (part) { + case "ctrl": + case "control": + keybind.ctrl = true + break + case "meta": + case "cmd": + case "command": + keybind.meta = true + break + case "mod": + if (IS_MAC) keybind.meta = true + else keybind.ctrl = true + break + case "alt": + case "option": + keybind.alt = true + break + case "shift": + keybind.shift = true + break + default: + keybind.key = part + break + } + } + + return keybind + }) +} + +export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean { + const eventKey = event.key.toLowerCase() + + for (const kb of keybinds) { + const keyMatch = kb.key === eventKey + const ctrlMatch = kb.ctrl === (event.ctrlKey || false) + const metaMatch = kb.meta === (event.metaKey || false) + const shiftMatch = kb.shift === (event.shiftKey || false) + const altMatch = kb.alt === (event.altKey || false) + + if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) { + return true + } + } + + return false +} + +export function formatKeybind(config: string): string { + if (!config || config === "none") return "" + + const keybinds = parseKeybind(config) + if (keybinds.length === 0) return "" + + const kb = keybinds[0] + const parts: string[] = [] + + if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl") + if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt") + if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift") + if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") + + if (kb.key) { + const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1) + parts.push(displayKey) + } + + return IS_MAC ? parts.join("") : parts.join("+") +} + +function DialogCommand(props: { options: CommandOption[] }) { + const dialog = useDialog() + + return ( + <Dialog title="Commands"> + <List + class="px-2.5" + search={{ placeholder: "Search commands", autofocus: true }} + emptyMessage="No commands found" + items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)} + key={(x) => x?.id} + filterKeys={["title", "description", "category"]} + groupBy={(x) => x.category ?? ""} + onSelect={(option) => { + if (option) { + dialog.close() + option.onSelect?.("palette") + } + }} + > + {(option) => ( + <div class="w-full flex items-center justify-between gap-4"> + <div class="flex items-center gap-2 min-w-0"> + <span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span> + <Show when={option.description}> + <span class="text-14-regular text-text-weak truncate">{option.description}</span> + </Show> + </div> + <Show when={option.keybind}> + <span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span> + </Show> + </div> + )} + </List> + </Dialog> + ) +} + +export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ + name: "Command", + init: () => { + const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([]) + const [suspendCount, setSuspendCount] = createSignal(0) + const dialog = useDialog() + + const options = createMemo(() => { + const all = registrations().flatMap((x) => x()) + const suggested = all.filter((x) => x.suggested && !x.disabled) + return [ + ...suggested.map((x) => ({ + ...x, + id: "suggested." + x.id, + category: "Suggested", + })), + ...all, + ] + }) + + const suspended = () => suspendCount() > 0 + + const showPalette = () => { + if (!dialog.active) { + dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />) + } + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (suspended()) return + + const paletteKeybinds = parseKeybind("mod+shift+p") + if (matchKeybind(paletteKeybinds, event)) { + event.preventDefault() + showPalette() + return + } + + for (const option of options()) { + if (option.disabled) continue + if (!option.keybind) continue + + const keybinds = parseKeybind(option.keybind) + if (matchKeybind(keybinds, event)) { + event.preventDefault() + option.onSelect?.("keybind") + return + } + } + } + + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + + return { + register(cb: () => CommandOption[]) { + const results = createMemo(cb) + setRegistrations((arr) => [results, ...arr]) + onCleanup(() => { + setRegistrations((arr) => arr.filter((x) => x !== results)) + }) + }, + trigger(id: string, source?: "palette" | "keybind" | "slash") { + for (const option of options()) { + if (option.id === id || option.id === "suggested." + id) { + option.onSelect?.(source) + return + } + } + }, + show: showPalette, + keybinds(enabled: boolean) { + setSuspendCount((count) => count + (enabled ? -1 : 1)) + }, + suspended, + get options() { + return options() + }, + } + }, +}) diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 8151a2c6f..ad3a3bf18 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -13,6 +13,7 @@ import { type SessionStatus, type ProviderListResponse, type ProviderAuthResponse, + type Command, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -24,6 +25,7 @@ import { onMount } from "solid-js" type State = { ready: boolean agent: Agent[] + command: Command[] project: string provider: ProviderListResponse config: Config @@ -79,6 +81,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple path: { state: "", config: "", worktree: "", directory: "", home: "" }, ready: false, agent: [], + command: [], session: [], session_status: {}, session_diff: {}, @@ -97,11 +100,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple async function loadSessions(directory: string) { globalSDK.client.session.list({ directory }).then((x) => { - const sessions = (x.data ?? []) + const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 + const nonArchived = (x.data ?? []) .slice() .filter((s) => !s.time.archived) .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, 5) + // Include at least 5 sessions, plus any updated in the last hour + const sessions = nonArchived.filter((s, i) => { + if (i < 5) return true + const updated = new Date(s.time.updated).getTime() + return updated > fourHoursAgo + }) const [, setStore] = child(directory) setStore("session", sessions) }) @@ -118,6 +127,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)), path: () => sdk.path.get().then((x) => setStore("path", x.data!)), agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), + command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])), session: () => loadSessions(directory), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)), @@ -128,11 +138,12 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple } globalSDK.event.listen((e) => { + console.log(e) const directory = e.name const event = e.details if (directory === "global") { - switch (event.type) { + switch (event?.type) { case "global.disposed": { bootstrap() break @@ -216,6 +227,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.removed": { + const messages = store.message[event.properties.sessionID] + if (!messages) break + const result = Binary.search(messages, event.properties.messageID, (m) => m.id) + if (result.found) { + setStore( + "message", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } case "message.part.updated": { const part = event.properties.part const parts = store.part[part.messageID] @@ -237,6 +263,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.part.removed": { + const parts = store.part[event.properties.messageID] + if (!parts) break + const result = Binary.search(parts, event.properties.partID, (p) => p.id) + if (result.found) { + setStore( + "part", + event.properties.messageID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } } }) @@ -248,9 +289,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple globalSDK.client.project.list().then(async (x) => { setGlobalStore( "project", - x - .data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs) - .sort((a, b) => a.id.localeCompare(b.id)), + x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)), ) }), globalSDK.client.provider.list().then((x) => { diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 925bf4d4c..af71c6a00 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -1,5 +1,5 @@ -import { createStore } from "solid-js/store" -import { createMemo, onMount } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { batch, createMemo, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSync } from "./global-sync" @@ -22,6 +22,11 @@ export function getAvatarColors(key?: string) { } } +type SessionTabs = { + active?: string + all: string[] +} + export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -41,9 +46,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, + sessionTabs: {} as Record<string, SessionTabs>, }), { - name: "layout.v2", + name: "layout.v3", }, ) @@ -155,6 +161,86 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, + tabs(sessionKey: string) { + const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) + return { + tabs, + active: createMemo(() => tabs().active), + all: createMemo(() => tabs().all), + setActive(tab: string | undefined) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + setAll(all: string[]) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all, active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "all", all) + } + }, + async open(tab: string) { + if (tab === "chat") { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "active", undefined) + } + return + } + const current = store.sessionTabs[sessionKey] ?? { all: [] } + if (tab !== "review") { + if (!current.all.includes(tab)) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [tab], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "all", [...current.all, tab]) + setStore("sessionTabs", sessionKey, "active", tab) + } + return + } + } + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + close(tab: string) { + const current = store.sessionTabs[sessionKey] + if (!current) return + batch(() => { + setStore( + "sessionTabs", + sessionKey, + "all", + current.all.filter((x) => x !== tab), + ) + if (current.active === tab) { + const index = current.all.findIndex((f) => f === tab) + const previous = current.all[Math.max(0, index - 1)] + setStore("sessionTabs", sessionKey, "active", previous) + } + }) + }, + move(tab: string, to: number) { + const current = store.sessionTabs[sessionKey] + if (!current) return + const index = current.all.findIndex((f) => f === tab) + if (index === -1) return + setStore( + "sessionTabs", + sessionKey, + "all", + produce((opened) => { + opened.splice(to, 0, opened.splice(index, 1)[0]) + }), + ) + }, + } + }, } }, }) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 56154c5ba..b12679210 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -249,6 +249,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ set(model: ModelKey | undefined, options?: { recent?: boolean }) { batch(() => { setEphemeral("model", agent.current().name, model ?? fallbackModel()) + if (model) updateVisibility(model, "show") if (options?.recent && model) { const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) if (uniq.length > 5) uniq.pop() @@ -405,7 +406,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ case "file.watcher.updated": const relativePath = relative(event.properties.file) if (relativePath.startsWith(".git/")) return - load(relativePath) + if (store.node[relativePath]) load(relativePath) break } }) diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx index 744e4fdf3..ee15bc34a 100644 --- a/packages/desktop/src/context/notification.tsx +++ b/packages/desktop/src/context/notification.tsx @@ -2,9 +2,12 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSDK } from "./global-sdk" +import { useGlobalSync } from "./global-sync" +import { Binary } from "@opencode-ai/util/binary" import { EventSessionError } from "@opencode-ai/sdk/v2" import { makeAudioPlayer } from "@solid-primitives/audio" import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" +import errorSound from "@opencode-ai/ui/audio/nope-03.aac" type NotificationBase = { directory?: string @@ -29,7 +32,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi name: "Notification", init: () => { const idlePlayer = makeAudioPlayer(idleSound) + const errorPlayer = makeAudioPlayer(errorSound) const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() const [store, setStore] = makePersisted( createStore({ @@ -46,6 +51,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi // }) globalSDK.event.listen((e) => { + console.log(e) const directory = e.name const event = e.details const base = { @@ -55,22 +61,32 @@ export const { use: useNotification, provider: NotificationProvider } = createSi } switch (event.type) { case "session.idle": { + const sessionID = event.properties.sessionID + const [syncStore] = globalSync.child(directory) + const match = Binary.search(syncStore.session, sessionID, (s) => s.id) + const isChild = match.found && syncStore.session[match.index].parentID + if (isChild) break idlePlayer.play() - const session = event.properties.sessionID setStore("list", store.list.length, { ...base, type: "turn-complete", - session, + session: sessionID, }) break } case "session.error": { - const session = event.properties.sessionID ?? "global" - // errorPlayer.play() + const sessionID = event.properties.sessionID + if (sessionID) { + const [syncStore] = globalSync.child(directory) + const match = Binary.search(syncStore.session, sessionID, (s) => s.id) + const isChild = match.found && syncStore.session[match.index].parentID + if (isChild) break + } + errorPlayer.play() setStore("list", store.list.length, { ...base, type: "error", - session, + session: sessionID ?? "global", error: "error" in event.properties ? event.properties.error : undefined, }) break diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx new file mode 100644 index 000000000..2da0a08d5 --- /dev/null +++ b/packages/desktop/src/context/prompt.tsx @@ -0,0 +1,112 @@ +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { useParams } from "@solidjs/router" +import { TextSelection } from "./local" + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: TextSelection +} + +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart +export type Prompt = ContentPart[] + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + const partA = promptA[i] + const partB = promptB[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB as TextPart).content) { + return false + } + if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { + return false + } + if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { + return false + } + } + return true +} + +function cloneSelection(selection?: TextSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ + name: "Prompt", + init: () => { + const params = useParams() + const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore] = makePersisted( + createStore<{ + prompt: Prompt + cursor?: number + }>({ + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: undefined, + }), + { + name: name(), + }, + ) + + return { + current: createMemo(() => store.prompt), + cursor: createMemo(() => store.cursor), + dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } + }, +}) diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx deleted file mode 100644 index 860c1a14f..000000000 --- a/packages/desktop/src/context/session.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { createStore, produce } from "solid-js/store" -import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo } from "solid-js" -import { useSync } from "./sync" -import { makePersisted } from "@solid-primitives/storage" -import { TextSelection } from "./local" -import { pipe, sumBy } from "remeda" -import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" -import { useParams } from "@solidjs/router" -import { useSDK } from "./sdk" - -export type LocalPTY = { - id: string - title: string - rows?: number - cols?: number - buffer?: string - scrollY?: number -} - -export const { use: useSession, provider: SessionProvider } = createSimpleContext({ - name: "Session", - init: () => { - const sdk = useSDK() - const params = useParams() - const sync = useSync() - const name = createMemo(() => `${params.dir}/session${params.id ? "/" + params.id : ""}.v3`) - - const [store, setStore] = makePersisted( - createStore<{ - messageId?: string - tabs: { - active?: string - all: string[] - } - prompt: Prompt - cursor?: number - terminals: { - active?: string - all: LocalPTY[] - } - }>({ - tabs: { - all: [], - }, - prompt: clonePrompt(DEFAULT_PROMPT), - cursor: undefined, - terminals: { all: [] }, - }), - { - name: name(), - }, - ) - - createEffect(() => { - if (!params.id) return - sync.session.sync(params.id) - }) - - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const userMessages = createMemo(() => - messages() - .filter((m) => m.role === "user") - .sort((a, b) => a.id.localeCompare(b.id)), - ) - const lastUserMessage = createMemo(() => { - return userMessages()?.at(-1) - }) - const activeMessage = createMemo(() => { - if (!store.messageId) return lastUserMessage() - return userMessages()?.find((m) => m.id === store.messageId) - }) - const status = createMemo( - () => - sync.data.session_status[params.id ?? ""] ?? { - type: "idle", - }, - ) - const working = createMemo(() => status()?.type !== "idle") - - const cost = createMemo(() => { - const total = pipe( - messages(), - sumBy((x) => (x.role === "assistant" ? x.cost : 0)), - ) - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(total) - }) - - const last = createMemo( - () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, - ) - const model = createMemo(() => - last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, - ) - const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - - const tokens = createMemo(() => { - if (!last()) return - const tokens = last().tokens - return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write - }) - - const context = createMemo(() => { - const total = tokens() - const limit = model()?.limit.context - if (!total || !limit) return 0 - return Math.round((total / limit) * 100) - }) - - return { - get id() { - return params.id - }, - info, - status, - working, - diffs, - prompt: { - current: createMemo(() => store.prompt), - cursor: createMemo(() => store.cursor), - dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - }, - messages: { - all: messages, - user: userMessages, - last: lastUserMessage, - active: activeMessage, - setActive(message: UserMessage | undefined) { - setStore("messageId", message?.id) - }, - }, - usage: { - tokens, - cost, - context, - }, - layout: { - tabs: store.tabs, - setActiveTab(tab: string | undefined) { - setStore("tabs", "active", tab) - }, - setOpenedTabs(tabs: string[]) { - setStore("tabs", "all", tabs) - }, - async openTab(tab: string) { - if (tab === "chat") { - setStore("tabs", "active", undefined) - return - } - if (tab !== "review") { - if (!store.tabs.all.includes(tab)) { - setStore("tabs", "all", [...store.tabs.all, tab]) - } - } - setStore("tabs", "active", tab) - }, - closeTab(tab: string) { - batch(() => { - setStore( - "tabs", - "all", - store.tabs.all.filter((x) => x !== tab), - ) - if (store.tabs.active === tab) { - const index = store.tabs.all.findIndex((f) => f === tab) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("tabs", "active", previous) - } - }) - }, - moveTab(tab: string, to: number) { - const index = store.tabs.all.findIndex((f) => f === tab) - if (index === -1) return - setStore( - "tabs", - "all", - produce((opened) => { - opened.splice(to, 0, opened.splice(index, 1)[0]) - }), - ) - }, - }, - terminal: { - all: createMemo(() => Object.values(store.terminals.all)), - active: createMemo(() => store.terminals.active), - new() { - sdk.client.pty.create({ title: `Terminal ${store.terminals.all.length + 1}` }).then((pty) => { - const id = pty.data?.id - if (!id) return - setStore("terminals", "all", [ - ...store.terminals.all, - { - id, - title: pty.data?.title ?? "Terminal", - }, - ]) - setStore("terminals", "active", id) - }) - }, - update(pty: Partial<LocalPTY> & { id: string }) { - setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty.update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - }, - async clone(id: string) { - const index = store.terminals.all.findIndex((x) => x.id === id) - const pty = store.terminals.all[index] - if (!pty) return - const clone = await sdk.client.pty.create({ - title: pty.title, - }) - if (!clone.data) return - setStore("terminals", "all", index, { - ...pty, - ...clone.data, - }) - if (store.terminals.active === pty.id) { - setStore("terminals", "active", clone.data.id) - } - }, - open(id: string) { - setStore("terminals", "active", id) - }, - async close(id: string) { - batch(() => { - setStore( - "terminals", - "all", - store.terminals.all.filter((x) => x.id !== id), - ) - if (store.terminals.active === id) { - const index = store.terminals.all.findIndex((f) => f.id === id) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("terminals", "active", previous) - } - }) - await sdk.client.pty.remove({ ptyID: id }) - }, - move(id: string, to: number) { - const index = store.terminals.all.findIndex((f) => f.id === id) - if (index === -1) return - setStore( - "terminals", - "all", - produce((all) => { - all.splice(to, 0, all.splice(index, 1)[0]) - }), - ) - }, - }, - } - }, -}) - -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: TextSelection -} - -export type ContentPart = TextPart | FileAttachmentPart -export type Prompt = ContentPart[] - -export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] - -export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { - if (promptA.length !== promptB.length) return false - for (let i = 0; i < promptA.length; i++) { - const partA = promptA[i] - const partB = promptB[i] - if (partA.type !== partB.type) return false - if (partA.type === "text" && partA.content !== (partB as TextPart).content) { - return false - } - if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { - return false - } - } - return true -} - -function cloneSelection(selection?: TextSelection) { - if (!selection) return undefined - return { ...selection } -} - -function clonePart(part: ContentPart): ContentPart { - if (part.type === "text") return { ...part } - return { - ...part, - selection: cloneSelection(part.selection), - } -} - -function clonePrompt(prompt: Prompt): Prompt { - return prompt.map(clonePart) -} diff --git a/packages/desktop/src/context/terminal.tsx b/packages/desktop/src/context/terminal.tsx new file mode 100644 index 000000000..cf9b5a5b9 --- /dev/null +++ b/packages/desktop/src/context/terminal.tsx @@ -0,0 +1,106 @@ +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { useParams } from "@solidjs/router" +import { useSDK } from "./sdk" + +export type LocalPTY = { + id: string + title: string + rows?: number + cols?: number + buffer?: string + scrollY?: number +} + +export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ + name: "Terminal", + init: () => { + const sdk = useSDK() + const params = useParams() + const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore] = makePersisted( + createStore<{ + active?: string + all: LocalPTY[] + }>({ + all: [], + }), + { + name: name(), + }, + ) + + return { + all: createMemo(() => Object.values(store.all)), + active: createMemo(() => store.active), + new() { + sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => { + const id = pty.data?.id + if (!id) return + setStore("all", [ + ...store.all, + { + id, + title: pty.data?.title ?? "Terminal", + }, + ]) + setStore("active", id) + }) + }, + update(pty: Partial<LocalPTY> & { id: string }) { + setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) + sdk.client.pty.update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + }, + async clone(id: string) { + const index = store.all.findIndex((x) => x.id === id) + const pty = store.all[index] + if (!pty) return + const clone = await sdk.client.pty.create({ + title: pty.title, + }) + if (!clone.data) return + setStore("all", index, { + ...pty, + ...clone.data, + }) + if (store.active === pty.id) { + setStore("active", clone.data.id) + } + }, + open(id: string) { + setStore("active", id) + }, + async close(id: string) { + batch(() => { + setStore( + "all", + store.all.filter((x) => x.id !== id), + ) + if (store.active === id) { + const index = store.all.findIndex((f) => f.id === id) + const previous = store.all[Math.max(0, index - 1)] + setStore("active", previous?.id) + } + }) + await sdk.client.pty.remove({ ptyID: id }) + }, + move(id: string, to: number) { + const index = store.all.findIndex((f) => f.id === id) + if (index === -1) return + setStore( + "all", + produce((all) => { + all.splice(to, 0, all.splice(index, 1)[0]) + }), + ) + }, + } + }, +}) diff --git a/packages/desktop/src/hooks/use-providers.ts b/packages/desktop/src/hooks/use-providers.ts index 501ff9d0c..4a73fa055 100644 --- a/packages/desktop/src/hooks/use-providers.ts +++ b/packages/desktop/src/hooks/use-providers.ts @@ -6,8 +6,8 @@ import { createMemo } from "solid-js" export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] export function useProviders() { - const params = useParams() const globalSync = useGlobalSync() + const params = useParams() const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) const providers = createMemo(() => { if (currentDirectory()) { diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx index 0dbb3f6d6..c909a373d 100644 --- a/packages/desktop/src/pages/directory-layout.tsx +++ b/packages/desktop/src/pages/directory-layout.tsx @@ -6,7 +6,6 @@ import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" -import { DialogRoot } from "@opencode-ai/ui/context/dialog" export default function Layout(props: ParentProps) { const params = useParams() @@ -21,9 +20,7 @@ export default function Layout(props: ParentProps) { const sync = useSync() return ( <DataProvider data={sync.data} directory={directory()}> - <LocalProvider> - <DialogRoot>{props.children}</DialogRoot> - </LocalProvider> + <LocalProvider>{props.children}</LocalProvider> </DataProvider> ) })} diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 7560e43e6..79470cf14 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -12,6 +12,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { Spinner } from "@opencode-ai/ui/spinner" import { getFilename } from "@opencode-ai/util/path" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session, Project } from "@opencode-ai/sdk/v2/client" @@ -35,6 +36,7 @@ import { Binary } from "@opencode-ai/util/binary" import { Header } from "@/components/header" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectProvider } from "@/components/dialog-select-provider" +import { useCommand } from "@/context/command" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -42,6 +44,16 @@ export default function Layout(props: ParentProps) { activeDraggable: undefined as string | undefined, }) + let scrollContainerRef: HTMLDivElement | undefined + + function scrollToSession(sessionId: string) { + if (!scrollContainerRef) return + const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`) + if (element) { + element.scrollIntoView({ block: "center", behavior: "smooth" }) + } + } + const params = useParams() const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() @@ -51,9 +63,161 @@ export default function Layout(props: ParentProps) { const navigate = useNavigate() const providers = useProviders() const dialog = useDialog() + const command = useCommand() + + function flattenSessions(sessions: Session[]): Session[] { + const childrenMap = new Map<string, Session[]>() + for (const session of sessions) { + if (session.parentID) { + const children = childrenMap.get(session.parentID) ?? [] + children.push(session) + childrenMap.set(session.parentID, children) + } + } + const result: Session[] = [] + function visit(session: Session) { + result.push(session) + for (const child of childrenMap.get(session.id) ?? []) { + visit(child) + } + } + for (const session of sessions) { + if (!session.parentID) visit(session) + } + return result + } + + const currentSessions = createMemo(() => { + if (!params.dir) return [] + const directory = base64Decode(params.dir) + return flattenSessions(globalSync.child(directory)[0].session ?? []) + }) + + function navigateSessionByOffset(offset: number) { + const projects = layout.projects.list() + if (projects.length === 0) return + + const currentDirectory = params.dir ? base64Decode(params.dir) : undefined + const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1 + + if (projectIndex === -1) { + const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1] + if (targetProject) navigateToProject(targetProject.worktree) + return + } + + const sessions = currentSessions() + const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 + + let targetIndex: number + if (sessionIndex === -1) { + targetIndex = offset > 0 ? 0 : sessions.length - 1 + } else { + targetIndex = sessionIndex + offset + } + + if (targetIndex >= 0 && targetIndex < sessions.length) { + const session = sessions[targetIndex] + navigateToSession(session) + queueMicrotask(() => scrollToSession(session.id)) + return + } + + const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1) + const nextProject = projects[nextProjectIndex] + if (!nextProject) return + + const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? []) + if (nextProjectSessions.length === 0) { + navigateToProject(nextProject.worktree) + return + } + + const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1] + navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`) + queueMicrotask(() => scrollToSession(targetSession.id)) + } + + async function archiveSession(session: Session) { + const [store, setStore] = globalSync.child(session.directory) + const sessions = store.session ?? [] + const index = sessions.findIndex((s) => s.id === session.id) + const nextSession = sessions[index + 1] ?? sessions[index - 1] + + await globalSDK.client.session.update({ + directory: session.directory, + sessionID: session.id, + time: { archived: Date.now() }, + }) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, session.id, (s) => s.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + if (session.id === params.id) { + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + } else { + navigate(`/${params.dir}/session`) + } + } + } + + command.register(() => [ + { + id: "sidebar.toggle", + title: "Toggle sidebar", + category: "View", + keybind: "mod+b", + onSelect: () => layout.sidebar.toggle(), + }, + ...(platform.openDirectoryPickerDialog + ? [ + { + id: "project.open", + title: "Open project", + category: "Project", + keybind: "mod+o", + onSelect: () => chooseProject(), + }, + ] + : []), + { + id: "provider.connect", + title: "Connect provider", + category: "Provider", + onSelect: () => connectProvider(), + }, + { + id: "session.previous", + title: "Previous session", + category: "Session", + keybind: "alt+arrowup", + onSelect: () => navigateSessionByOffset(-1), + }, + { + id: "session.next", + title: "Next session", + category: "Session", + keybind: "alt+arrowdown", + onSelect: () => navigateSessionByOffset(1), + }, + { + id: "session.archive", + title: "Archive session", + category: "Session", + keybind: "mod+shift+backspace", + disabled: !params.dir || !params.id, + onSelect: () => { + const session = currentSessions().find((s) => s.id === params.id) + if (session) archiveSession(session) + }, + }, + ]) function connectProvider() { - dialog.replace(() => <DialogSelectProvider />) + dialog.show(() => <DialogSelectProvider />) } function navigateToProject(directory: string | undefined) { @@ -236,13 +400,117 @@ export default function Layout(props: ParentProps) { ) } - const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => { + const SessionItem = (props: { + session: Session + slug: string + project: Project + depth?: number + childrenMap: Map<string, Session[]> + }): JSX.Element => { const notification = useNotification() + const depth = props.depth ?? 0 + const children = createMemo(() => props.childrenMap.get(props.session.id) ?? []) + const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) + const notifications = createMemo(() => notification.session.unseen(props.session.id)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const isWorking = createMemo( + () => + props.session.id !== params.id && + globalSync.child(props.project.worktree)[0].session_status[props.session.id]?.type === "busy", + ) + return ( + <> + <div + data-session-id={props.session.id} + class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors + hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" + style={{ "padding-left": `${16 + depth * 12}px` }} + > + <Tooltip placement="right" value={props.session.title} gutter={10}> + <A + href={`${props.slug}/session/${props.session.id}`} + class="flex flex-col min-w-0 text-left w-full focus:outline-none" + > + <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7"> + <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> + {props.session.title} + </span> + <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> + <Switch> + <Match when={isWorking()}> + <Spinner class="size-2.5 mr-0.5" /> + </Match> + <Match when={hasError()}> + <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" /> + </Match> + <Match when={notifications().length > 0}> + <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" /> + </Match> + <Match when={true}> + <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> + {Math.abs(updated().diffNow().as("seconds")) < 60 + ? "Now" + : updated() + .toRelative({ + style: "short", + unit: ["days", "hours", "minutes"], + }) + ?.replace(" ago", "") + ?.replace(/ days?/, "d") + ?.replace(" min.", "m") + ?.replace(" hr.", "h")} + </span> + </Match> + </Switch> + </div> + </div> + <Show when={props.session.summary?.files}> + <div class="flex justify-between items-center self-stretch"> + <span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span> + <Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show> + </div> + </Show> + </A> + </Tooltip> + <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1"> + <Tooltip placement="right" value="Archive session"> + <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} /> + </Tooltip> + </div> + </div> + <For each={children()}> + {(child) => ( + <SessionItem + session={child} + slug={props.slug} + project={props.project} + depth={depth + 1} + childrenMap={props.childrenMap} + /> + )} + </For> + </> + ) + } + + const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => { const sortable = createSortable(props.project.worktree) const slug = createMemo(() => base64Encode(props.project.worktree)) const name = createMemo(() => getFilename(props.project.worktree)) - const [store, setStore] = globalSync.child(props.project.worktree) + const [store] = globalSync.child(props.project.worktree) const sessions = createMemo(() => store.session ?? []) + const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) + const childSessionsByParent = createMemo(() => { + const map = new Map<string, Session[]>() + for (const session of sessions()) { + if (session.parentID) { + const children = map.get(session.parentID) ?? [] + children.push(session) + map.set(session.parentID, children) + } + } + return map + }) const [expanded, setExpanded] = createSignal(true) return ( // @ts-ignore @@ -282,83 +550,17 @@ export default function Layout(props: ParentProps) { </Button> <Collapsible.Content> <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5"> - <For each={sessions()}> - {(session) => { - const updated = createMemo(() => DateTime.fromMillis(session.time.updated)) - const notifications = createMemo(() => notification.session.unseen(session.id)) - const hasError = createMemo(() => notifications().some((n) => n.type === "error")) - async function archive(session: Session) { - await globalSDK.client.session.update({ - directory: session.directory, - sessionID: session.id, - time: { archived: Date.now() }, - }) - setStore( - produce((draft) => { - const match = Binary.search(draft.session, session.id, (s) => s.id) - if (match.found) draft.session.splice(match.index, 1) - }), - ) - } - return ( - <div - class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors - hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" - > - <Tooltip placement="right" value={session.title} gutter={10}> - <A - href={`${slug()}/session/${session.id}`} - class="flex flex-col min-w-0 text-left w-full focus:outline-none" - > - <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7"> - <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> - {session.title} - </span> - <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> - <Switch> - <Match when={hasError()}> - <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" /> - </Match> - <Match when={notifications().length > 0}> - <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" /> - </Match> - <Match when={true}> - <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> - {Math.abs(updated().diffNow().as("seconds")) < 60 - ? "Now" - : updated() - .toRelative({ - style: "short", - unit: ["days", "hours", "minutes"], - }) - ?.replace(" ago", "") - ?.replace(/ days?/, "d") - ?.replace(" min.", "m") - ?.replace(" hr.", "h")} - </span> - </Match> - </Switch> - </div> - </div> - <Show when={session.summary?.files}> - <div class="flex justify-between items-center self-stretch"> - <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span> - <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show> - </div> - </Show> - </A> - </Tooltip> - <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1"> - {/* <IconButton icon="dot-grid" variant="ghost" /> */} - <Tooltip placement="right" value="Archive session"> - <IconButton icon="archive" variant="ghost" onClick={() => archive(session)} /> - </Tooltip> - </div> - </div> - ) - }} + <For each={rootSessions()}> + {(session) => ( + <SessionItem + session={session} + slug={slug()} + project={props.project} + childrenMap={childSessionsByParent()} + /> + )} </For> - <Show when={sessions().length === 0}> + <Show when={rootSessions().length === 0}> <div class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" @@ -471,7 +673,10 @@ export default function Layout(props: ParentProps) { > <DragDropSensors /> <ConstrainDragXAxis /> - <div class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"> + <div + ref={scrollContainerRef} + class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar" + > <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}> <For each={layout.projects.list()}>{(project) => <SortableProject project={project} />}</For> </SortableProvider> diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index bef12fbd8..390872d36 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect, on } from "solid-js" import { useLocal, type LocalFile } from "@/context/local" import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" @@ -27,113 +27,333 @@ import { import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" -import { useSession, type LocalPTY } from "@/context/session" +import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" +import { DialogSelectModel } from "@/components/dialog-select-model" +import { useCommand } from "@/context/command" +import { useNavigate, useParams } from "@solidjs/router" +import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" +import { useSDK } from "@/context/sdk" +import { usePrompt } from "@/context/prompt" +import { extractPromptFromParts } from "@/utils/prompt" export default function Page() { const layout = useLayout() const local = useLocal() const sync = useSync() - const session = useSession() + const terminal = useTerminal() const dialog = useDialog() + const command = useCommand() + const params = useParams() + const navigate = useNavigate() + const sdk = useSDK() + const prompt = usePrompt() + + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) + + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const revertMessageID = createMemo(() => info()?.revert?.messageID) + const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const userMessages = createMemo(() => + messages() + .filter((m) => m.role === "user") + .sort((a, b) => a.id.localeCompare(b.id)), + ) + // Visible user messages excludes reverted messages (those >= revertMessageID) + const visibleUserMessages = createMemo(() => { + const revert = revertMessageID() + if (!revert) return userMessages() + return userMessages().filter((m) => m.id < revert) + }) + const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1)) + + const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({}) + const activeMessage = createMemo(() => { + if (!messageStore.messageId) return lastUserMessage() + // If the stored message is no longer visible (e.g., was reverted), fall back to last visible + const found = visibleUserMessages()?.find((m) => m.id === messageStore.messageId) + return found ?? lastUserMessage() + }) + const setActiveMessage = (message: UserMessage | undefined) => { + setMessageStore("messageId", message?.id) + } + + function navigateMessageByOffset(offset: number) { + const msgs = visibleUserMessages() + if (msgs.length === 0) return + + const current = activeMessage() + const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1 + + let targetIndex: number + if (currentIndex === -1) { + targetIndex = offset > 0 ? 0 : msgs.length - 1 + } else { + targetIndex = currentIndex + offset + } + + if (targetIndex < 0 || targetIndex >= msgs.length) return + + setActiveMessage(msgs[targetIndex]) + } + + const last = createMemo( + () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, + ) + const model = createMemo(() => + last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, + ) + const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + + const tokens = createMemo(() => { + if (!last()) return + const t = last().tokens + return t.input + t.output + t.reasoning + t.cache.read + t.cache.write + }) + + const context = createMemo(() => { + const total = tokens() + const limit = model()?.limit.context + if (!total || !limit) return 0 + return Math.round((total / limit) * 100) + }) + const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined, + stepsExpanded: false, }) let inputRef!: HTMLDivElement - const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" - - onMount(() => { - document.addEventListener("keydown", handleKeyDown) - }) - - onCleanup(() => { - document.removeEventListener("keydown", handleKeyDown) + createEffect(() => { + if (!params.id) return + sync.session.sync(params.id) }) createEffect(() => { if (layout.terminal.opened()) { - if (session.terminal.all().length === 0) { - session.terminal.new() + if (terminal.all().length === 0) { + terminal.new() } } }) + createEffect( + on( + () => visibleUserMessages().at(-1)?.id, + (lastId, prevLastId) => { + if (lastId && prevLastId && lastId > prevLastId) { + setMessageStore("messageId", undefined) + } + }, + { defer: true }, + ), + ) + + const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" }) + + command.register(() => [ + { + id: "session.new", + title: "New session", + description: "Create a new session", + category: "Session", + keybind: "mod+shift+s", + slash: "new", + onSelect: () => navigate(`/${params.dir}/session`), + }, + { + id: "file.open", + title: "Open file", + description: "Search and open a file", + category: "File", + keybind: "mod+p", + slash: "open", + onSelect: () => dialog.show(() => <DialogSelectFile />), + }, + // { + // id: "theme.toggle", + // title: "Toggle theme", + // description: "Switch between themes", + // category: "View", + // keybind: "ctrl+t", + // slash: "theme", + // onSelect: () => { + // const currentTheme = localStorage.getItem("theme") ?? "oc-1" + // const themes = ["oc-1", "oc-2-paper"] + // const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length] + // localStorage.setItem("theme", nextTheme) + // document.documentElement.setAttribute("data-theme", nextTheme) + // }, + // }, + { + id: "terminal.toggle", + title: "Toggle terminal", + description: "Show or hide the terminal", + category: "View", + keybind: "ctrl+`", + slash: "terminal", + onSelect: () => layout.terminal.toggle(), + }, + { + id: "terminal.new", + title: "New terminal", + description: "Create a new terminal tab", + category: "Terminal", + keybind: "ctrl+shift+`", + onSelect: () => terminal.new(), + }, + { + id: "steps.toggle", + title: "Toggle steps", + description: "Show or hide the steps", + category: "View", + keybind: "mod+e", + slash: "steps", + disabled: !params.id, + onSelect: () => setStore("stepsExpanded", (x) => !x), + }, + { + id: "message.previous", + title: "Previous message", + description: "Go to the previous user message", + category: "Session", + keybind: "mod+arrowup", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(-1), + }, + { + id: "message.next", + title: "Next message", + description: "Go to the next user message", + category: "Session", + keybind: "mod+arrowdown", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(1), + }, + { + id: "model.choose", + title: "Choose model", + description: "Select a different model", + category: "Model", + keybind: "mod+'", + slash: "model", + onSelect: () => dialog.show(() => <DialogSelectModel />), + }, + { + id: "agent.cycle", + title: "Cycle agent", + description: "Switch to the next agent", + category: "Agent", + keybind: "mod+.", + slash: "agent", + onSelect: () => local.agent.move(1), + }, + { + id: "session.undo", + title: "Undo", + description: "Undo the last message", + category: "Session", + keybind: "mod+z", + slash: "undo", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + if (status()?.type !== "idle") { + await sdk.client.session.abort({ sessionID }).catch(() => {}) + } + const revert = info()?.revert?.messageID + // Find the last user message that's not already reverted + const message = userMessages().findLast((x) => !revert || x.id < revert) + if (!message) return + await sdk.client.session.revert({ sessionID, messageID: message.id }) + // Restore the prompt from the reverted message + const parts = sync.data.part[message.id] + if (parts) { + const restored = extractPromptFromParts(parts) + prompt.set(restored) + } + // Navigate to the message before the reverted one (which will be the new last visible message) + const priorMessage = userMessages().findLast((x) => x.id < message.id) + setActiveMessage(priorMessage) + }, + }, + { + id: "session.redo", + title: "Redo", + description: "Redo the last undone message", + category: "Session", + keybind: "mod+shift+z", + slash: "redo", + disabled: !params.id || !info()?.revert?.messageID, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + const revertMessageID = info()?.revert?.messageID + if (!revertMessageID) return + const nextMessage = userMessages().find((x) => x.id > revertMessageID) + if (!nextMessage) { + // Full unrevert - restore all messages and navigate to last + await sdk.client.session.unrevert({ sessionID }) + prompt.reset() + // Navigate to the last message (the one that was at the revert point) + const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID) + setActiveMessage(lastMsg) + return + } + // Partial redo - move forward to next message + await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) + // Navigate to the message before the new revert point + const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id) + setActiveMessage(priorMsg) + }, + }, + ]) + const handleKeyDown = (event: KeyboardEvent) => { - if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { - event.preventDefault() - return - } - if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") { - event.preventDefault() - dialog.replace(() => <DialogSelectFile />) - return - } - if (event.ctrlKey && event.key.toLowerCase() === "t") { - event.preventDefault() - const currentTheme = localStorage.getItem("theme") ?? "oc-1" - const themes = ["oc-1", "oc-2-paper"] - const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length] - localStorage.setItem("theme", nextTheme) - document.documentElement.setAttribute("data-theme", nextTheme) - return - } - if (event.ctrlKey && event.key.toLowerCase() === "`") { - event.preventDefault() - if (event.shiftKey) { - session.terminal.new() - return - } - layout.terminal.toggle() - return - } + if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return + if (dialog.active) return - // @ts-expect-error - if (document.activeElement?.dataset?.component === "terminal") { + if (event.key === "PageUp" || event.key === "PageDown") { + const scrollContainer = document.querySelector('[data-slot="session-turn-content"]') as HTMLElement + if (scrollContainer) { + event.preventDefault() + const scrollAmount = scrollContainer.clientHeight * 0.8 + scrollContainer.scrollBy({ + top: event.key === "PageUp" ? -scrollAmount : scrollAmount, + behavior: "instant", + }) + } return } const focused = document.activeElement === inputRef if (focused) { - if (event.key === "Escape") { - inputRef?.blur() - } + if (event.key === "Escape") inputRef?.blur() return } - // if (local.file.active()) { - // const active = local.file.active()! - // if (event.key === "Enter" && active.selection) { - // local.context.add({ - // type: "file", - // path: active.path, - // selection: { ...active.selection }, - // }) - // return - // } - // - // if (event.getModifierState(MOD)) { - // if (event.key.toLowerCase() === "a") { - // return - // } - // if (event.key.toLowerCase() === "c") { - // return - // } - // } - // } - if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } } + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + const resetClickTimer = () => { if (!store.clickTimer) return clearTimeout(store.clickTimer) @@ -167,11 +387,11 @@ export default function Page() { const handleDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const currentTabs = session.layout.tabs.all + const currentTabs = tabs().all() const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const toIndex = currentTabs?.indexOf(droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== undefined) { - session.layout.moveTab(draggable.id.toString(), toIndex) + tabs().move(draggable.id.toString(), toIndex) } } } @@ -189,11 +409,11 @@ export default function Page() { const handleTerminalDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const terminals = session.terminal.all() - const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString()) - const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString()) + const terminals = terminal.all() + const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString()) + const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString()) if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { - session.terminal.move(draggable.id.toString(), toIndex) + terminal.move(draggable.id.toString(), toIndex) } } } @@ -211,8 +431,8 @@ export default function Page() { <Tabs.Trigger value={props.terminal.id} closeButton={ - session.terminal.all().length > 1 && ( - <IconButton icon="close" variant="ghost" onClick={() => session.terminal.close(props.terminal.id)} /> + terminal.all().length > 1 && ( + <IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} /> ) } > @@ -327,7 +547,7 @@ export default function Page() { return typeof draggable.id === "string" ? draggable.id : undefined } - const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length) + const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length) return ( <div class="relative bg-background-base size-full overflow-x-hidden flex flex-col"> @@ -340,7 +560,7 @@ export default function Page() { > <DragDropSensors /> <ConstrainDragYAxis /> - <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}> + <Tabs value={tabs().active() ?? "chat"} onChange={tabs().open}> <div class="sticky top-0 shrink-0 flex"> <Tabs.List> <Tabs.Trigger value="chat"> @@ -350,15 +570,15 @@ export default function Page() { value={`${new Intl.NumberFormat("en-US", { notation: "compact", compactDisplay: "short", - }).format(session.usage.tokens() ?? 0)} Tokens`} + }).format(tokens() ?? 0)} Tokens`} class="flex items-center gap-1.5" > - <ProgressCircle percentage={session.usage.context() ?? 0} /> - <div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div> + <ProgressCircle percentage={context() ?? 0} /> + <div class="text-14-regular text-text-weak text-left w-7">{context() ?? 0}%</div> </Tooltip> </div> </Tabs.Trigger> - <Show when={layout.review.state() === "tab" && session.diffs().length}> + <Show when={layout.review.state() === "tab" && diffs().length}> <Tabs.Trigger value="review" closeButton={ @@ -368,25 +588,23 @@ export default function Page() { } > <div class="flex items-center gap-3"> - <Show when={session.diffs()}> - <DiffChanges changes={session.diffs()} variant="bars" /> + <Show when={diffs()}> + <DiffChanges changes={diffs()} variant="bars" /> </Show> <div class="flex items-center gap-1.5"> <div>Review</div> - <Show when={session.info()?.summary?.files}> + <Show when={info()?.summary?.files}> <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base"> - {session.info()?.summary?.files ?? 0} + {info()?.summary?.files ?? 0} </div> </Show> </div> </div> </Tabs.Trigger> </Show> - <SortableProvider ids={session.layout.tabs.all ?? []}> - <For each={session.layout.tabs.all ?? []}> - {(tab) => ( - <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} /> - )} + <SortableProvider ids={tabs().all() ?? []}> + <For each={tabs().all() ?? []}> + {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />} </For> </SortableProvider> <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3"> @@ -395,7 +613,7 @@ export default function Page() { icon="plus-small" variant="ghost" iconSize="large" - onClick={() => dialog.replace(() => <DialogSelectFile />)} + onClick={() => dialog.show(() => <DialogSelectFile />)} /> </Tooltip> </div> @@ -416,29 +634,33 @@ export default function Page() { }} > <Switch> - <Match when={session.id}> + <Match when={params.id}> <div class="flex items-start justify-start h-full min-h-0"> <SessionMessageRail - messages={session.messages.user()} - current={session.messages.active()} - onMessageSelect={session.messages.setActive} + messages={visibleUserMessages()} + current={activeMessage()} + onMessageSelect={setActiveMessage} wide={wide()} /> - <SessionTurn - sessionID={session.id!} - messageID={session.messages.active()?.id!} - classes={{ - root: "pb-20 flex-1 min-w-0", - content: "pb-20", - container: - "w-full " + - (wide() - ? "max-w-146 mx-auto px-6" - : session.messages.user().length > 1 - ? "pr-6 pl-18" - : "px-6"), - }} - /> + <Show when={activeMessage()}> + <SessionTurn + sessionID={params.id!} + messageID={activeMessage()!.id} + stepsExpanded={store.stepsExpanded} + onStepsExpandedChange={(expanded) => setStore("stepsExpanded", expanded)} + classes={{ + root: "pb-20 flex-1 min-w-0", + content: "pb-20", + container: + "w-full " + + (wide() + ? "max-w-146 mx-auto px-6" + : visibleUserMessages().length > 1 + ? "pr-6 pl-18" + : "px-6"), + }} + /> + </Show> </div> </Match> <Match when={true}> @@ -477,7 +699,7 @@ export default function Page() { </div> </div> </div> - <Show when={layout.review.state() === "pane" && session.diffs().length}> + <Show when={layout.review.state() === "pane" && diffs().length}> <div classList={{ "relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true, @@ -489,7 +711,7 @@ export default function Page() { header: "px-6", container: "px-6", }} - diffs={session.diffs()} + diffs={diffs()} actions={ <Tooltip value="Open in tab"> <IconButton @@ -497,7 +719,7 @@ export default function Page() { variant="ghost" onClick={() => { layout.review.tab() - session.layout.setActiveTab("review") + tabs().setActive("review") }} /> </Tooltip> @@ -507,7 +729,7 @@ export default function Page() { </Show> </div> </Tabs.Content> - <Show when={layout.review.state() === "tab" && session.diffs().length}> + <Show when={layout.review.state() === "tab" && diffs().length}> <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden"> <div classList={{ @@ -520,13 +742,13 @@ export default function Page() { header: "px-6", container: "px-6", }} - diffs={session.diffs()} + diffs={diffs()} split /> </div> </Tabs.Content> </Show> - <For each={session.layout.tabs.all}> + <For each={tabs().all()}> {(tab) => { const [file] = createResource( () => tab, @@ -580,7 +802,7 @@ export default function Page() { </Show> </DragOverlay> </DragDropProvider> - <Show when={session.layout.tabs.active}> + <Show when={tabs().active()}> <div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8"> <PromptInput ref={(el) => { @@ -589,34 +811,6 @@ export default function Page() { /> </div> </Show> - <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto"> - {/* <FileTree path="" onFileClick={ handleTabClick} /> */} - </div> - <div class="hidden shrink-0 w-56 p-2"> - <Show - when={local.file.changes().length} - fallback={<div class="px-2 text-xs text-text-muted">No changes</div>} - > - <ul class=""> - <For each={local.file.changes()}> - {(path) => ( - <li> - <button - onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })} - class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element" - > - <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" /> - <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span> - <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0"> - {getDirectory(path)} - </span> - </button> - </li> - )} - </For> - </ul> - </Show> - </div> </div> <Show when={layout.terminal.opened()}> <div @@ -640,25 +834,21 @@ export default function Page() { > <DragDropSensors /> <ConstrainDragYAxis /> - <Tabs variant="alt" value={session.terminal.active()} onChange={session.terminal.open}> + <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}> <Tabs.List class="h-10"> - <SortableProvider ids={session.terminal.all().map((t) => t.id)}> - <For each={session.terminal.all()}>{(terminal) => <SortableTerminalTab terminal={terminal} />}</For> + <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}> + <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For> </SortableProvider> <div class="h-full flex items-center justify-center"> <Tooltip value="New Terminal" class="flex items-center"> - <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} /> + <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} /> </Tooltip> </div> </Tabs.List> - <For each={session.terminal.all()}> - {(terminal) => ( - <Tabs.Content value={terminal.id}> - <Terminal - pty={terminal} - onCleanup={session.terminal.update} - onConnectError={() => session.terminal.clone(terminal.id)} - /> + <For each={terminal.all()}> + {(pty) => ( + <Tabs.Content value={pty.id}> + <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} /> </Tabs.Content> )} </For> @@ -666,9 +856,9 @@ export default function Page() { <DragOverlay> <Show when={store.activeTerminalDraggable}> {(draggedId) => { - const terminal = createMemo(() => session.terminal.all().find((t) => t.id === draggedId())) + const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) return ( - <Show when={terminal()}> + <Show when={pty()}> {(t) => ( <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular"> {t().title} diff --git a/packages/desktop/src/utils/prompt.ts b/packages/desktop/src/utils/prompt.ts new file mode 100644 index 000000000..45c5ce1f3 --- /dev/null +++ b/packages/desktop/src/utils/prompt.ts @@ -0,0 +1,47 @@ +import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2" +import type { Prompt, FileAttachmentPart } from "@/context/prompt" + +/** + * Extract prompt content from message parts for restoring into the prompt input. + * This is used by undo to restore the original user prompt. + */ +export function extractPromptFromParts(parts: Part[]): Prompt { + const result: Prompt = [] + let position = 0 + + for (const part of parts) { + if (part.type === "text") { + const textPart = part as TextPart + if (!textPart.synthetic && textPart.text) { + result.push({ + type: "text", + content: textPart.text, + start: position, + end: position + textPart.text.length, + }) + position += textPart.text.length + } + } else if (part.type === "file") { + const filePart = part as FilePart + if (filePart.source?.type === "file") { + const path = filePart.source.path + const content = "@" + path + const attachment: FileAttachmentPart = { + type: "file", + path, + content, + start: position, + end: position + content.length, + } + result.push(attachment) + position += content.length + } + } + } + + if (result.length === 0) { + result.push({ type: "text", content: "", start: 0, end: 0 }) + } + + return result +} diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index d0589587b..89243ffeb 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.153", + "version": "1.0.162", "private": true, "type": "module", "scripts": { @@ -14,7 +14,7 @@ "@opencode-ai/util": "workspace:*", "@opencode-ai/ui": "workspace:*", "aws4fetch": "^1.0.20", - "@pierre/precision-diffs": "catalog:", + "@pierre/diffs": "catalog:", "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", "@solidjs/meta": "catalog:", diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 8fe6f0c1b..83cc030f9 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -19,7 +19,7 @@ import { createStore } from "solid-js/store" import z from "zod" import NotFound from "../[...404]" import { Tabs } from "@opencode-ai/ui/tabs" -import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr" +import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr" import { clientOnly } from "@solidjs/start" import { type IconName } from "@opencode-ai/ui/icons/provider" diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 52f948655..782f989ba 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.0.153" +version = "1.0.162" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/sst/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.162/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.162/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-linux-arm64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.162/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-linux-x64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.162/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.162/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index c2ee790ce..f1c3cf78a 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.153", + "version": "1.0.162", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", @@ -12,7 +12,7 @@ }, "dependencies": { "@octokit/auth-app": "8.0.1", - "@octokit/rest": "22.0.0", + "@octokit/rest": "catalog:", "hono": "catalog:", "jose": "6.0.11" } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index bbeb9ae0a..a3798c4ba 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.153", + "version": "1.0.162", "name": "opencode", "type": "module", "private": true, @@ -64,7 +64,7 @@ "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.15.1", "@octokit/graphql": "9.0.2", - "@octokit/rest": "22.0.0", + "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", @@ -74,7 +74,7 @@ "@opentui/core": "0.0.0-20251211-4403a69a", "@opentui/solid": "0.0.0-20251211-4403a69a", "@parcel/watcher": "2.5.1", - "@pierre/precision-diffs": "catalog:", + "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ef007df13..add120f91 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -107,6 +107,24 @@ export namespace Agent { ) const result: Record<string, Info> = { + build: { + name: "build", + tools: { ...defaultTools }, + options: {}, + permission: agentPermission, + mode: "primary", + native: true, + }, + plan: { + name: "plan", + options: {}, + permission: planPermission, + tools: { + ...defaultTools, + }, + mode: "primary", + native: true, + }, general: { name: "general", description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, @@ -149,14 +167,6 @@ export namespace Agent { options: {}, permission: agentPermission, }, - build: { - name: "build", - tools: { ...defaultTools }, - options: {}, - permission: agentPermission, - mode: "primary", - native: true, - }, title: { name: "title", mode: "primary", @@ -177,16 +187,6 @@ export namespace Agent { prompt: PROMPT_SUMMARY, tools: {}, }, - plan: { - name: "plan", - options: {}, - permission: planPermission, - tools: { - ...defaultTools, - }, - mode: "primary", - native: true, - }, } for (const [key, value] of Object.entries(cfg.agent ?? {})) { if (value.disable) { @@ -256,9 +256,9 @@ export namespace Agent { return state().then((x) => Object.values(x)) } - export async function generate(input: { description: string }) { + export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) { const cfg = await Config.get() - const defaultModel = await Provider.defaultModel() + 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) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 2cbcfbfe9..60dd9cc75 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -3,6 +3,7 @@ import * as prompts from "@clack/prompts" import { UI } from "../ui" import { Global } from "../../global" import { Agent } from "../../agent/agent" +import { Provider } from "../../provider/provider" import path from "path" import fs from "fs/promises" import matter from "gray-matter" @@ -47,6 +48,11 @@ const AgentCreateCommand = cmd({ .option("tools", { type: "string", describe: `comma-separated list of tools to enable (default: all). Available: "${AVAILABLE_TOOLS.join(", ")}"`, + }) + .option("model", { + type: "string", + alias: ["m"], + describe: "model to use in the format of provider/model", }), async handler(args) { await Instance.provide({ @@ -114,7 +120,8 @@ const AgentCreateCommand = cmd({ // Generate agent const spinner = prompts.spinner() spinner.start("Generating agent configuration...") - const generated = await Agent.generate({ description }).catch((error) => { + const model = args.model ? Provider.parseModel(args.model) : undefined + const generated = await Agent.generate({ description, model }).catch((error) => { spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) if (isFullyNonInteractive) process.exit(1) throw new UI.CancelledError() diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index dab1196f4..480a38230 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -278,7 +278,7 @@ export const GithubInstallCommand = cmd({ process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" - ? `start "${url}"` + ? `start "" "${url}"` : `xdg-open "${url}"` exec(command, (error) => { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 69db202ee..a1a8a5e80 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -298,6 +298,24 @@ function App() { }, }, { + title: "Favorite cycle", + value: "model.cycle_favorite", + keybind: "model_cycle_favorite", + category: "Agent", + onSelect: () => { + local.model.cycleFavorite(1) + }, + }, + { + title: "Favorite cycle reverse", + value: "model.cycle_favorite_reverse", + keybind: "model_cycle_favorite_reverse", + category: "Agent", + onSelect: () => { + local.model.cycleFavorite(-1) + }, + }, + { title: "Switch agent", value: "agent.list", keybind: "agent_list", 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 37e6ccda5..6fde66944 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -364,6 +364,13 @@ export function Autocomplete(props: { const result = fuzzysort.go(currentFilter, mixed, { keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""], limit: 10, + scoreFn: (objResults) => { + const displayResult = objResults[0] + if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) { + return objResults.score * 2 + } + return objResults.score + }, }) return result.map((arr) => arr.obj) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx index e55479c02..e90503e9f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -9,6 +9,7 @@ import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2" export type PromptInfo = { input: string + mode?: "normal" | "shell" parts: ( | Omit<FilePart, "id" | "messageID" | "sessionID"> | Omit<AgentPart, "id" | "messageID" | "sessionID"> 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 eefe43d1f..938405f68 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -495,6 +495,9 @@ export function Prompt(props: PromptProps) { // Filter out text parts (pasted content) since they're now expanded inline const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") + // Capture mode before it gets reset + const currentMode = store.mode + if (store.mode === "shell") { sdk.client.session.shell({ sessionID, @@ -543,7 +546,10 @@ export function Prompt(props: PromptProps) { ], }) } - history.append(store.prompt) + history.append({ + ...store.prompt, + mode: currentMode, + }) input.extmarks.clear() setStore("prompt", { input: "", @@ -767,6 +773,7 @@ export function Prompt(props: PromptProps) { if (item) { input.setText(item.input) setStore("prompt", item) + setStore("mode", item.mode ?? "normal") restoreExtmarksFromParts(item.parts) e.preventDefault() if (direction === -1) input.cursorOffset = 0 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 48f7db054..b9ef2580b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -64,6 +64,7 @@ import { Editor } from "../../util/editor" import stripAnsi from "strip-ansi" import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" +import { Filesystem } from "@/util/filesystem" addDefaultParsers(parsers.parsers) @@ -1414,7 +1415,10 @@ ToolRegistry.register<typeof WriteTool>({ return props.input.content }) - const diagnostics = createMemo(() => props.metadata.diagnostics?.[props.input.filePath ?? ""] ?? []) + const diagnostics = createMemo(() => { + const filePath = Filesystem.normalizePath(props.input.filePath ?? "") + return props.metadata.diagnostics?.[filePath] ?? [] + }) return ( <> @@ -1587,7 +1591,8 @@ ToolRegistry.register<typeof EditTool>({ const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"]) const diagnostics = createMemo(() => { - const arr = props.metadata.diagnostics?.[props.input.filePath ?? ""] ?? [] + const filePath = Filesystem.normalizePath(props.input.filePath ?? "") + const arr = props.metadata.diagnostics?.[filePath] ?? [] return arr.filter((x) => x.severity === 1).slice(0, 3) }) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 3f49a7c32..8e9c17f70 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -307,10 +307,9 @@ function Option(props: { fg={props.active ? fg : props.current ? theme.primary : theme.text} attributes={props.active ? TextAttributes.BOLD : undefined} overflow="hidden" - wrapMode="word" paddingLeft={3} > - {Locale.truncate(props.title, 62)} + {Locale.truncate(props.title, 61)} <Show when={props.description}> <span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span> </Show> diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 333e19848..9cf3507e1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -460,6 +460,8 @@ export namespace Config { model_list: z.string().optional().default("<leader>m").describe("List available models"), model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), + model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), + model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), command_list: z.string().optional().default("ctrl+p").describe("List available commands"), agent_list: z.string().optional().default("<leader>a").describe("List agents"), agent_cycle: z.string().optional().default("tab").describe("Next agent"), @@ -668,10 +670,16 @@ export namespace Config { .describe("@deprecated Use `agent` field instead."), agent: z .object({ + // primary plan: Agent.optional(), build: Agent.optional(), + // subagent general: Agent.optional(), explore: Agent.optional(), + // specialized + title: Agent.optional(), + summary: Agent.optional(), + compaction: Agent.optional(), }) .catchall(Agent) .optional() diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index ce426cf62..b66bb9933 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -11,6 +11,9 @@ import type { LSPServer } from "./server" import { NamedError } from "@opencode-ai/util/error" import { withTimeout } from "../util/timeout" import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" + +const DIAGNOSTICS_DEBOUNCE_MS = 150 export namespace LSPClient { const log = Log.create({ service: "lsp.client" }) @@ -47,14 +50,15 @@ export namespace LSPClient { const diagnostics = new Map<string, Diagnostic[]>() connection.onNotification("textDocument/publishDiagnostics", (params) => { - const path = fileURLToPath(params.uri) + const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) l.info("textDocument/publishDiagnostics", { - path, + path: filePath, + count: params.diagnostics.length, }) - const exists = diagnostics.has(path) - diagnostics.set(path, params.diagnostics) + const exists = diagnostics.has(filePath) + diagnostics.set(filePath, params.diagnostics) if (!exists && input.serverID === "typescript") return - Bus.publish(Event.Diagnostics, { path, serverID: input.serverID }) + Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) }) connection.onRequest("window/workDoneProgress/create", (params) => { l.info("window/workDoneProgress/create", params) @@ -181,16 +185,23 @@ export namespace LSPClient { return diagnostics }, async waitForDiagnostics(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) - log.info("waiting for diagnostics", input) + const normalizedPath = Filesystem.normalizePath( + path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path), + ) + log.info("waiting for diagnostics", { path: normalizedPath }) let unsub: () => void + let debounceTimer: ReturnType<typeof setTimeout> | undefined return await withTimeout( new Promise<void>((resolve) => { unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path === input.path && event.properties.serverID === result.serverID) { - log.info("got diagnostics", input) - unsub?.() - resolve() + if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) { + // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax) + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + log.info("got diagnostics", { path: normalizedPath }) + unsub?.() + resolve() + }, DIAGNOSTICS_DEBOUNCE_MS) } }) }), @@ -198,6 +209,7 @@ export namespace LSPClient { ) .catch(() => {}) .finally(() => { + if (debounceTimer) clearTimeout(debounceTimer) unsub?.() }) }, diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index e3e3fdf7d..939a31a2d 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -9,6 +9,7 @@ import fs from "fs/promises" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" +import { Archive } from "../util/archive" export namespace LSPServer { const log = Log.create({ service: "lsp.server" }) @@ -176,7 +177,13 @@ export namespace LSPServer { const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip") await Bun.file(zipPath).write(response) - await $`unzip -o -q ${zipPath}`.quiet().cwd(Global.Path.bin).nothrow() + const ok = await Archive.extractZip(zipPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract vscode-eslint archive", { error }) + return false + }) + if (!ok) return await fs.rm(zipPath, { force: true }) const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main") @@ -281,7 +288,7 @@ export namespace LSPServer { extensions: [".go"], async spawn(root) { let bin = Bun.which("gopls", { - PATH: process.env["PATH"] + ":" + Global.Path.bin, + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, }) if (!bin) { if (!Bun.which("go")) return @@ -319,7 +326,7 @@ export namespace LSPServer { extensions: [".rb", ".rake", ".gemspec", ".ru"], async spawn(root) { let bin = Bun.which("rubocop", { - PATH: process.env["PATH"] + ":" + Global.Path.bin, + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, }) if (!bin) { const ruby = Bun.which("ruby") @@ -420,7 +427,7 @@ export namespace LSPServer { Global.Path.bin, "elixir-ls-master", "release", - process.platform === "win32" ? "language_server.bar" : "language_server.sh", + process.platform === "win32" ? "language_server.bat" : "language_server.sh", ) if (!(await Bun.file(binary).exists())) { @@ -438,7 +445,13 @@ export namespace LSPServer { const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") await Bun.file(zipPath).write(response) - await $`unzip -o -q ${zipPath}`.quiet().cwd(Global.Path.bin).nothrow() + const ok = await Archive.extractZip(zipPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract elixir-ls archive", { error }) + return false + }) + if (!ok) return await fs.rm(zipPath, { force: true, @@ -470,7 +483,7 @@ export namespace LSPServer { root: NearestRoot(["build.zig"]), async spawn(root) { let bin = Bun.which("zls", { - PATH: process.env["PATH"] + ":" + Global.Path.bin, + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, }) if (!bin) { @@ -541,7 +554,13 @@ export namespace LSPServer { await Bun.file(tempPath).write(downloadResponse) if (ext === "zip") { - await $`unzip -o -q ${tempPath}`.quiet().cwd(Global.Path.bin).nothrow() + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract zls archive", { error }) + return false + }) + if (!ok) return } else { await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).nothrow() } @@ -576,7 +595,7 @@ export namespace LSPServer { extensions: [".cs"], async spawn(root) { let bin = Bun.which("csharp-ls", { - PATH: process.env["PATH"] + ":" + Global.Path.bin, + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, }) if (!bin) { if (!Bun.which("dotnet")) { @@ -610,6 +629,46 @@ export namespace LSPServer { }, } + export const FSharp: Info = { + id: "fsharp", + root: NearestRoot([".sln", ".fsproj", "global.json"]), + extensions: [".fs", ".fsi", ".fsx", ".fsscript"], + async spawn(root) { + let bin = Bun.which("fsautocomplete", { + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, + }) + if (!bin) { + if (!Bun.which("dotnet")) { + log.error(".NET SDK is required to install fsautocomplete") + return + } + + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("installing fsautocomplete via dotnet tool") + const proc = Bun.spawn({ + cmd: ["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + log.error("Failed to install fsautocomplete") + return + } + + bin = path.join(Global.Path.bin, "fsautocomplete" + (process.platform === "win32" ? ".exe" : "")) + log.info(`installed fsautocomplete`, { bin }) + } + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, + } + export const SourceKit: Info = { id: "sourcekit-lsp", extensions: [".swift", ".objc", "objcpp"], @@ -800,7 +859,13 @@ export namespace LSPServer { } if (zip) { - await $`unzip -o -q ${archive}`.quiet().cwd(Global.Path.bin).nothrow() + const ok = await Archive.extractZip(archive, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract clangd archive", { error }) + return false + }) + if (!ok) return } if (tar) { await $`tar -xf ${archive}`.cwd(Global.Path.bin).nothrow() @@ -1070,7 +1135,7 @@ export namespace LSPServer { extensions: [".lua"], async spawn(root) { let bin = Bun.which("lua-language-server", { - PATH: process.env["PATH"] + ":" + Global.Path.bin, + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, }) if (!bin) { @@ -1148,14 +1213,21 @@ export namespace LSPServer { await fs.mkdir(installDir, { recursive: true }) if (ext === "zip") { - const ok = await $`unzip -o -q ${tempPath} -d ${installDir}`.quiet().catch((error) => { - log.error("Failed to extract lua-language-server archive", { error }) - }) + const ok = await Archive.extractZip(tempPath, installDir) + .then(() => true) + .catch((error) => { + log.error("Failed to extract lua-language-server archive", { error }) + return false + }) if (!ok) return } else { - const ok = await $`tar -xzf ${tempPath} -C ${installDir}`.quiet().catch((error) => { - log.error("Failed to extract lua-language-server archive", { error }) - }) + const ok = await $`tar -xzf ${tempPath} -C ${installDir}` + .quiet() + .then(() => true) + .catch((error) => { + log.error("Failed to extract lua-language-server archive", { error }) + return false + }) if (!ok) return } @@ -1309,7 +1381,7 @@ export namespace LSPServer { root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), async spawn(root) { let bin = Bun.which("terraform-ls", { - PATH: process.env["PATH"] + ":" + Global.Path.bin, + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, }) if (!bin) { @@ -1356,7 +1428,13 @@ export namespace LSPServer { const tempPath = path.join(Global.Path.bin, assetName) await Bun.file(tempPath).write(downloadResponse) - await $`unzip -o -q ${tempPath}`.cwd(Global.Path.bin).nothrow() + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract terraform-ls archive", { error }) + return false + }) + if (!ok) return await fs.rm(tempPath, { force: true }) bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : "")) @@ -1393,7 +1471,7 @@ export namespace LSPServer { root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), async spawn(root) { let bin = Bun.which("texlab", { - PATH: process.env["PATH"] + ":" + Global.Path.bin, + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, }) if (!bin) { @@ -1441,7 +1519,13 @@ export namespace LSPServer { await Bun.file(tempPath).write(downloadResponse) if (ext === "zip") { - await $`unzip -o -q ${tempPath}`.cwd(Global.Path.bin).nothrow() + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract texlab archive", { error }) + return false + }) + if (!ok) return } if (ext === "tar.gz") { await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).nothrow() diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 9af5589e8..957ec47da 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -205,7 +205,12 @@ export namespace ProviderTransform { export function message(msgs: ModelMessage[], model: Provider.Model) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model) - if (model.providerID === "anthropic" || model.api.id.includes("anthropic") || model.api.id.includes("claude")) { + if ( + model.providerID === "anthropic" || + model.api.id.includes("anthropic") || + model.api.id.includes("claude") || + model.api.npm === "@ai-sdk/anthropic" + ) { msgs = applyCaching(msgs, model.providerID) } diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index d192eaf1f..5ea479c63 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -114,8 +114,12 @@ export namespace Pty { const id = Identifier.create("pty", false) const command = input.command || Shell.preferred() const args = input.args || [] + if (command.endsWith("sh")) { + args.push("-l") + } + const cwd = input.cwd || Instance.directory - const env = { ...process.env, ...input.env } as Record<string, string> + const env = { ...process.env, ...input.env, TERM: "xterm-256color" } as Record<string, string> log.info("creating session", { id, cmd: command, args, cwd }) const spawn = await pty() diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f1485ec01..f1d4ecd8d 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -125,6 +125,14 @@ export namespace Server { async (c) => { log.info("global event connected") return streamSSE(c, async (stream) => { + stream.writeSSE({ + data: JSON.stringify({ + payload: { + type: "server.connected", + properties: {}, + }, + }), + }) async function handler(event: any) { await stream.writeSSE({ data: JSON.stringify(event), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 565d037f4..ce7b60f0a 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -60,11 +60,18 @@ export namespace LLM { .join("\n"), ) + const header = system[0] const original = clone(system) await Plugin.trigger("experimental.chat.system.transform", {}, { system }) if (system.length === 0) { system.push(...original) } + // rejoin to maintain 2-part structure for caching if header unchanged + if (system.length > 2 && system[0] === header) { + const rest = system.slice(1) + system.length = 0 + system.push(header, rest.join("\n")) + } const params = await Plugin.trigger( "chat.params", @@ -80,6 +87,7 @@ export namespace LLM { ? (input.agent.temperature ?? ProviderTransform.temperature(input.model)) : undefined, topP: input.agent.topP ?? ProviderTransform.topP(input.model), + topK: ProviderTransform.topK(input.model), options: pipe( {}, mergeDeep(ProviderTransform.options(input.model, input.sessionID)), @@ -132,6 +140,7 @@ export namespace LLM { }, temperature: params.temperature, topP: params.topP, + topK: params.topK, providerOptions: ProviderTransform.providerOptions(input.model, params.options), activeTools: Object.keys(tools).filter((x) => x !== "invalid"), tools, diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index fdf115ac4..b49bd7abe 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -140,16 +140,14 @@ export const EditTool = Tool.define("edit", { let output = "" await LSP.touchFile(filePath, true) const diagnostics = await LSP.diagnostics() - for (const [file, issues] of Object.entries(diagnostics)) { - if (issues.length === 0) continue - if (file === filePath) { - const errors = issues.filter((item) => item.severity === 1) - const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) - const suffix = - errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" - output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n` - continue - } + const normalizedFilePath = Filesystem.normalizePath(filePath) + const issues = diagnostics[normalizedFilePath] ?? [] + if (issues.length > 0) { + const errors = issues.filter((item) => item.severity === 1) + const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) + const suffix = + errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" + output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n` } const filediff: Snapshot.FileDiff = { diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 03f2ba891..6b8fd3dd1 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -80,6 +80,7 @@ export const WriteTool = Tool.define("write", { let output = "" await LSP.touchFile(filepath, true) const diagnostics = await LSP.diagnostics() + const normalizedFilepath = Filesystem.normalizePath(filepath) let projectDiagnosticsCount = 0 for (const [file, issues] of Object.entries(diagnostics)) { if (issues.length === 0) continue @@ -87,7 +88,7 @@ export const WriteTool = Tool.define("write", { const limited = sorted.slice(0, MAX_DIAGNOSTICS_PER_FILE) const suffix = issues.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${issues.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" - if (file === filepath) { + if (file === normalizedFilepath) { output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n` continue } diff --git a/packages/opencode/src/util/archive.ts b/packages/opencode/src/util/archive.ts new file mode 100644 index 000000000..34a1738a8 --- /dev/null +++ b/packages/opencode/src/util/archive.ts @@ -0,0 +1,16 @@ +import { $ } from "bun" +import path from "path" + +export namespace Archive { + export async function extractZip(zipPath: string, destDir: string) { + if (process.platform === "win32") { + const winZipPath = path.resolve(zipPath) + const winDestDir = path.resolve(destDir) + // $global:ProgressPreference suppresses PowerShell's blue progress bar popup + const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force` + await $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet() + } else { + await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet() + } + } +} diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index a3dcfc703..98fbe533d 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,7 +1,21 @@ +import { realpathSync } from "fs" import { exists } from "fs/promises" import { dirname, join, relative } from "path" export namespace Filesystem { + /** + * On Windows, normalize a path to its canonical casing using the filesystem. + * This is needed because Windows paths are case-insensitive but LSP servers + * may return paths with different casing than what we send them. + */ + export function normalizePath(p: string): string { + if (process.platform !== "win32") return p + try { + return realpathSync.native(p) + } catch { + return p + } + } export function overlaps(a: string, b: string) { const relA = relative(a, b) const relB = relative(b, a) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 4a7841908..903808ae5 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.0.153", + "version": "1.0.162", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 9dd4820b9..ef06a4d8b 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -161,7 +161,7 @@ export interface Hooks { */ "chat.params"?: ( input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, - output: { temperature: number; topP: number; options: Record<string, any> }, + output: { temperature: number; topP: number; topK: number; options: Record<string, any> }, ) => Promise<void> "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void> "tool.execute.before"?: ( diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a18040830..78a81d99c 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.0.153", + "version": "1.0.162", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c466e78dc..ca8d25fd5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -918,6 +918,14 @@ export type KeybindsConfig = { */ model_cycle_recent_reverse?: string /** + * Next favorite model + */ + model_cycle_favorite?: string + /** + * Previous favorite model + */ + model_cycle_favorite_reverse?: string + /** * List available commands */ command_list?: string @@ -1413,6 +1421,9 @@ export type Config = { build?: AgentConfig general?: AgentConfig explore?: AgentConfig + title?: AgentConfig + summary?: AgentConfig + compaction?: AgentConfig [key: string]: AgentConfig | undefined } /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 71f1df312..3be96c599 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7169,6 +7169,16 @@ "default": "shift+f2", "type": "string" }, + "model_cycle_favorite": { + "description": "Next favorite model", + "default": "none", + "type": "string" + }, + "model_cycle_favorite_reverse": { + "description": "Previous favorite model", + "default": "none", + "type": "string" + }, "command_list": { "description": "List available commands", "default": "ctrl+p", @@ -7992,6 +8002,15 @@ }, "explore": { "$ref": "#/components/schemas/AgentConfig" + }, + "title": { + "$ref": "#/components/schemas/AgentConfig" + }, + "summary": { + "$ref": "#/components/schemas/AgentConfig" + }, + "compaction": { + "$ref": "#/components/schemas/AgentConfig" } }, "additionalProperties": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 7a5c339e8..8a07fad12 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.153", + "version": "1.0.162", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/tauri/package.json b/packages/tauri/package.json index 22fa35023..658d173a1 100644 --- a/packages/tauri/package.json +++ b/packages/tauri/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/tauri", "private": true, - "version": "1.0.153", + "version": "1.0.162", "type": "module", "scripts": { "typecheck": "tsgo -b", diff --git a/packages/tauri/scripts/predev.ts b/packages/tauri/scripts/predev.ts index 6b69a3ae5..218215197 100644 --- a/packages/tauri/scripts/predev.ts +++ b/packages/tauri/scripts/predev.ts @@ -1,4 +1,3 @@ -import * as fs from "node:fs/promises" import { $ } from "bun" import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils" diff --git a/packages/tauri/src-tauri/src/lib.rs b/packages/tauri/src-tauri/src/lib.rs index b06ccd06c..aab2ce5f3 100644 --- a/packages/tauri/src-tauri/src/lib.rs +++ b/packages/tauri/src-tauri/src/lib.rs @@ -183,6 +183,7 @@ pub fn run() { .inner_size(size.width as f64, size.height as f64) .decorations(true) .zoom_hotkeys_enabled(true) + .disable_drag_drop_handler() .initialization_script(format!( r#" window.__OPENCODE__ ??= {{}}; diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index 6813a218b..78d8e620a 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ }, "bundle": { "active": true, - "targets": ["deb", "rpm", "dmg", "nsis"], + "targets": ["deb", "rpm", "dmg", "nsis", "app"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/[email protected]", "icons/icon.icns", "icons/icon.ico"], "externalBin": ["sidecars/opencode-cli"], "createUpdaterArtifacts": true, diff --git a/packages/tauri/src/index.tsx b/packages/tauri/src/index.tsx index 84ba73c07..f3c329b34 100644 --- a/packages/tauri/src/index.tsx +++ b/packages/tauri/src/index.tsx @@ -1,12 +1,14 @@ // @refresh reload import { render } from "solid-js/web" import { App, PlatformProvider, Platform } from "@opencode-ai/desktop" -import { runUpdater } from "./updater" import { onMount } from "solid-js" import { open, save } from "@tauri-apps/plugin-dialog" import { open as shellOpen } from "@tauri-apps/plugin-shell" import { type as ostype } from "@tauri-apps/plugin-os" +import { runUpdater, UPDATER_ENABLED } from "./updater" +import { createMenu } from "./menu" + const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error( @@ -48,9 +50,11 @@ const platform: Platform = { }, } +createMenu() + render(() => { onMount(() => { - if (window.__OPENCODE__?.updaterEnabled) runUpdater() + if (UPDATER_ENABLED) runUpdater() }) return ( diff --git a/packages/tauri/src/menu.ts b/packages/tauri/src/menu.ts new file mode 100644 index 000000000..d32506d32 --- /dev/null +++ b/packages/tauri/src/menu.ts @@ -0,0 +1,94 @@ +import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu" +import { type as ostype } from "@tauri-apps/plugin-os" + +import { runUpdater, UPDATER_ENABLED } from "./updater" + +export async function createMenu() { + if (ostype() !== "macos") return + + const menu = await Menu.new({ + items: [ + await Submenu.new({ + text: "OpenCode", + items: [ + await PredefinedMenuItem.new({ + item: { About: null }, + }), + await MenuItem.new({ + enabled: UPDATER_ENABLED, + action: () => runUpdater(), + text: "Check For Updates...", + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + await PredefinedMenuItem.new({ + item: "Hide", + }), + await PredefinedMenuItem.new({ + item: "HideOthers", + }), + await PredefinedMenuItem.new({ + item: "ShowAll", + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + await PredefinedMenuItem.new({ + item: "Quit", + }), + ].filter(Boolean), + }), + // await Submenu.new({ + // text: "File", + // items: [ + // await MenuItem.new({ + // enabled: false, + // text: "Open Project...", + // }), + // await PredefinedMenuItem.new({ + // item: "Separator" + // }), + // await MenuItem.new({ + // enabled: false, + // text: "New Session", + // }), + // await PredefinedMenuItem.new({ + // item: "Separator" + // }), + // await MenuItem.new({ + // enabled: false, + // text: "Close Project", + // }) + // ] + // }), + await Submenu.new({ + text: "Edit", + items: [ + await PredefinedMenuItem.new({ + item: "Undo", + }), + await PredefinedMenuItem.new({ + item: "Redo", + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + await PredefinedMenuItem.new({ + item: "Cut", + }), + await PredefinedMenuItem.new({ + item: "Copy", + }), + await PredefinedMenuItem.new({ + item: "Paste", + }), + await PredefinedMenuItem.new({ + item: "SelectAll", + }), + ], + }), + ], + }) + menu.setAsAppMenu() +} diff --git a/packages/tauri/src/updater.ts b/packages/tauri/src/updater.ts index 0d3737d1e..d62aae424 100644 --- a/packages/tauri/src/updater.ts +++ b/packages/tauri/src/updater.ts @@ -2,6 +2,8 @@ import { check, DownloadEvent } from "@tauri-apps/plugin-updater" import { relaunch } from "@tauri-apps/plugin-process" import { ask, message } from "@tauri-apps/plugin-dialog" +export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false + export async function runUpdater(onDownloadEvent?: (progress: DownloadEvent) => void) { let update try { diff --git a/packages/ui/package.json b/packages/ui/package.json index 2632f6961..8ede50bc1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.153", + "version": "1.0.162", "type": "module", "exports": { "./*": "./src/components/*.tsx", @@ -35,7 +35,7 @@ "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", - "@pierre/precision-diffs": "catalog:", + "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", "@solid-primitives/resize-observer": "2.1.3", diff --git a/packages/ui/src/assets/audio/nope-01.aac b/packages/ui/src/assets/audio/nope-01.aac Binary files differnew file mode 100644 index 000000000..9fb614d08 --- /dev/null +++ b/packages/ui/src/assets/audio/nope-01.aac diff --git a/packages/ui/src/assets/audio/nope-02.aac b/packages/ui/src/assets/audio/nope-02.aac Binary files differnew file mode 100644 index 000000000..75603cc16 --- /dev/null +++ b/packages/ui/src/assets/audio/nope-02.aac diff --git a/packages/ui/src/assets/audio/nope-03.aac b/packages/ui/src/assets/audio/nope-03.aac Binary files differnew file mode 100644 index 000000000..1fe459a16 --- /dev/null +++ b/packages/ui/src/assets/audio/nope-03.aac diff --git a/packages/ui/src/assets/audio/nope-04.aac b/packages/ui/src/assets/audio/nope-04.aac Binary files differnew file mode 100644 index 000000000..b731a2a07 --- /dev/null +++ b/packages/ui/src/assets/audio/nope-04.aac diff --git a/packages/ui/src/assets/audio/nope-05.aac b/packages/ui/src/assets/audio/nope-05.aac Binary files differnew file mode 100644 index 000000000..4534191b6 --- /dev/null +++ b/packages/ui/src/assets/audio/nope-05.aac diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index c80f0987f..77696faed 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -1,4 +1,4 @@ -import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/precision-diffs" +import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/diffs" import { ComponentProps, createEffect, createMemo, splitProps } from "solid-js" import { createDefaultOptions, styleVariables } from "../pierre" import { workerPool } from "../pierre/worker" diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 47d6af42e..40a6ac83d 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -20,6 +20,14 @@ export function Dialog(props: DialogProps) { ...(props.classList ?? {}), [props.class ?? ""]: !!props.class, }} + onOpenAutoFocus={(e) => { + const target = e.currentTarget as HTMLElement | null + const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null + if (autofocusEl) { + e.preventDefault() + autofocusEl.focus() + } + }} > <Show when={props.title || props.action}> <div data-slot="dialog-header"> diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx index 800aa3730..b38b4a34f 100644 --- a/packages/ui/src/components/diff-ssr.tsx +++ b/packages/ui/src/components/diff-ssr.tsx @@ -1,5 +1,5 @@ -import { FileDiff } from "@pierre/precision-diffs" -import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr" +import { FileDiff } from "@pierre/diffs" +import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { onCleanup, onMount, Show, splitProps } from "solid-js" import { isServer } from "solid-js/web" import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre" @@ -65,11 +65,11 @@ export function Diff<T>(props: SSRDiffProps<T>) { return ( <div data-component="diff" style={styleVariables} ref={container}> - <file-diff ref={fileDiffRef} id="ssr-diff"> + <diffs-container ref={fileDiffRef} id="ssr-diff"> <Show when={isServer}> <template shadowrootmode="open" innerHTML={props.preloadedDiff.prerenderedHTML} /> </Show> - </file-diff> + </diffs-container> </div> ) } diff --git a/packages/ui/src/components/diff.css b/packages/ui/src/components/diff.css index 690667ea7..345271a12 100644 --- a/packages/ui/src/components/diff.css +++ b/packages/ui/src/components/diff.css @@ -19,8 +19,8 @@ position: sticky; background-color: var(--surface-diff-hidden-base); color: var(--text-base); - width: var(--pjs-column-content-width); - left: var(--pjs-column-number-width); + width: var(--diffs-column-content-width); + left: var(--diffs-column-number-width); padding-left: 8px; user-select: none; cursor: default; diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 703043f4c..75dde0440 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -1,4 +1,4 @@ -import { FileDiff } from "@pierre/precision-diffs" +import { FileDiff } from "@pierre/diffs" import { createEffect, createMemo, onCleanup, splitProps } from "solid-js" import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre" import { workerPool } from "../pierre/worker" diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 0dbd7a650..b8e8106e8 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -51,6 +51,7 @@ const icons = { "circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`, copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`, check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`, + photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index cd9e73d1d..368065e53 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -112,6 +112,7 @@ padding: 4px 10px; align-items: center; color: var(--text-strong); + scroll-margin-top: 28px; /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 7ec6e159d..0ed745f32 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -79,7 +79,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) return } const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) - element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + element?.scrollIntoView({ block: "center", behavior: "smooth" }) }) const handleSelect = (item: T | undefined, index: number) => { diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index e369f9220..01f34ceff 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -14,11 +14,78 @@ line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-base); - display: -webkit-box; - line-clamp: 3; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; + display: flex; + flex-direction: column; + gap: 8px; + + [data-slot="user-message-attachments"] { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + [data-slot="user-message-attachment"] { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 6px; + overflow: hidden; + background: var(--surface-base); + border: 1px solid var(--border-base); + transition: border-color 0.15s ease; + + &:hover { + border-color: var(--border-strong-base); + } + + &[data-type="image"] { + width: 48px; + height: 48px; + } + + &[data-type="file"] { + width: 48px; + height: 48px; + } + } + + [data-slot="user-message-attachment-image"] { + width: 100%; + height: 100%; + object-fit: cover; + } + + [data-slot="user-message-attachment-icon"] { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--icon-weak); + + [data-component="icon"] { + width: 20px; + height: 20px; + } + } + + [data-slot="user-message-text"] { + display: -webkit-box; + white-space: pre-wrap; + line-clamp: 3; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .text-text-strong { + color: var(--text-strong); + } + + .font-medium { + font-weight: var(--font-weight-medium); + } } [data-component="text-part"] { @@ -108,15 +175,19 @@ display: flex; align-items: center; justify-content: space-between; + gap: 8px; width: 100%; [data-slot="message-part-title-area"] { + flex-grow: 1; display: flex; align-items: center; gap: 8px; + min-width: 0; } [data-slot="message-part-title"] { + flex-shrink: 0; font-family: var(--font-family-sans); font-size: var(--font-size-base); font-style: normal; @@ -129,14 +200,22 @@ [data-slot="message-part-path"] { display: flex; + flex-grow: 1; + min-width: 0; } [data-slot="message-part-directory"] { color: var(--text-weak); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + direction: rtl; + text-align: left; } [data-slot="message-part-filename"] { color: var(--text-strong); + flex-shrink: 0; } [data-slot="message-part-actions"] { @@ -151,6 +230,23 @@ border-top: 1px solid var(--border-weaker-base); } +[data-component="write-content"] { + border-top: 1px solid var(--border-weaker-base); + max-height: 240px; + overflow-y: auto; + + [data-component="code"] { + padding-bottom: 0px !important; + } + + /* Hide scrollbar */ + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } +} + [data-component="tool-action"] { width: 24px; height: 24px; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index f00c43bd8..33b519ea4 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -2,6 +2,7 @@ import { Component, createMemo, For, Match, Show, Switch } from "solid-js" import { Dynamic } from "solid-js/web" import { AssistantMessage, + FilePart, Message as MessageType, Part as PartType, TextPart, @@ -74,13 +75,93 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part } export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { - const text = createMemo(() => - props.parts - ?.filter((p) => p.type === "text" && !(p as TextPart).synthetic) - ?.map((p) => (p as TextPart).text) - ?.join(""), + const textPart = createMemo( + () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined, + ) + + const text = createMemo(() => textPart()?.text || "") + + const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? []) + + const attachments = createMemo(() => + files()?.filter((f) => { + const mime = f.mime + return mime.startsWith("image/") || mime === "application/pdf" + }), + ) + + const inlineFiles = createMemo(() => + files().filter((f) => { + const mime = f.mime + return !mime.startsWith("image/") && mime !== "application/pdf" && f.source?.text?.start !== undefined + }), + ) + + return ( + <div data-component="user-message"> + <Show when={attachments().length > 0}> + <div data-slot="user-message-attachments"> + <For each={attachments()}> + {(file) => ( + <div data-slot="user-message-attachment" data-type={file.mime.startsWith("image/") ? "image" : "file"}> + <Show + when={file.mime.startsWith("image/") && file.url} + fallback={ + <div data-slot="user-message-attachment-icon"> + <Icon name="folder" /> + </div> + } + > + <img data-slot="user-message-attachment-image" src={file.url} alt={file.filename ?? "attachment"} /> + </Show> + </div> + )} + </For> + </div> + </Show> + <Show when={text()}> + <div data-slot="user-message-text"> + <HighlightedText text={text()} references={inlineFiles()} /> + </div> + </Show> + </div> + ) +} + +function HighlightedText(props: { text: string; references: FilePart[] }) { + const segments = createMemo(() => { + const text = props.text + const refs = [...props.references].sort((a, b) => (a.source?.text?.start ?? 0) - (b.source?.text?.start ?? 0)) + + const result: { text: string; highlight?: boolean }[] = [] + let lastIndex = 0 + + for (const ref of refs) { + const start = ref.source?.text?.start + const end = ref.source?.text?.end + + if (start === undefined || end === undefined || start < lastIndex) continue + + if (start > lastIndex) { + result.push({ text: text.slice(lastIndex, start) }) + } + + result.push({ text: text.slice(start, end), highlight: true }) + lastIndex = end + } + + if (lastIndex < text.length) { + result.push({ text: text.slice(lastIndex) }) + } + + return result + }) + + return ( + <For each={segments()}> + {(segment) => <span classList={{ "text-text-strong font-medium": segment.highlight }}>{segment.text}</span>} + </For> ) - return <div data-component="user-message">{text()}</div> } export function Part(props: MessagePartProps) { @@ -220,8 +301,12 @@ ToolRegistry.register({ render(props) { return ( <BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}> - <Show when={false && props.output}> - <div data-component="tool-output">{props.output}</div> + <Show when={props.output}> + {(output) => ( + <div data-component="tool-output" data-scrollable> + <Markdown text={output()} /> + </div> + )} </Show> </BasicTool> ) @@ -240,8 +325,12 @@ ToolRegistry.register({ args: props.input.pattern ? ["pattern=" + props.input.pattern] : [], }} > - <Show when={false && props.output}> - <div data-component="tool-output">{props.output}</div> + <Show when={props.output}> + {(output) => ( + <div data-component="tool-output" data-scrollable> + <Markdown text={output()} /> + </div> + )} </Show> </BasicTool> ) @@ -263,8 +352,12 @@ ToolRegistry.register({ args, }} > - <Show when={false && props.output}> - <div data-component="tool-output">{props.output}</div> + <Show when={props.output}> + {(output) => ( + <div data-component="tool-output" data-scrollable> + <Markdown text={output()} /> + </div> + )} </Show> </BasicTool> ) @@ -288,8 +381,12 @@ ToolRegistry.register({ ), }} > - <Show when={false && props.output}> - <div data-component="tool-output">{props.output}</div> + <Show when={props.output}> + {(output) => ( + <div data-component="tool-output" data-scrollable> + <Markdown text={output()} /> + </div> + )} </Show> </BasicTool> ) @@ -308,8 +405,12 @@ ToolRegistry.register({ subtitle: props.input.description, }} > - <Show when={false && props.output}> - <div data-component="tool-output">{props.output}</div> + <Show when={props.output}> + {(output) => ( + <div data-component="tool-output" data-scrollable> + <Markdown text={output()} /> + </div> + )} </Show> </BasicTool> ) @@ -387,6 +488,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "write", render(props) { + console.log(props) return ( <BasicTool icon="code-lines" @@ -405,9 +507,19 @@ ToolRegistry.register({ </div> } > - <Show when={false && props.output}> - <div data-component="tool-output">{props.output}</div> - </Show> + {/* <Show when={props.input.content}> */} + {/* <div data-component="write-content"> */} + {/* <Code */} + {/* file={{ */} + {/* name: props.input.filePath, */} + {/* contents: props.input.content, */} + {/* cacheKey: checksum(props.input.content), */} + {/* }} */} + {/* overflow="scroll" */} + {/* class="pb-40" */} + {/* /> */} + {/* </div> */} + {/* </Show> */} </BasicTool> ) }, @@ -418,6 +530,7 @@ ToolRegistry.register({ render(props) { return ( <BasicTool + defaultOpen icon="checklist" trigger={{ title: "To-dos", diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 8009091b7..b47ab55b1 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" import { For, Match, Show, Switch, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileDiff } from "@opencode-ai/sdk/v2" -import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr" +import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { Dynamic } from "solid-js/web" import { checksum } from "@opencode-ai/util/encode" diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index ad2e6c36e..0f5a26a2a 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -24,6 +24,8 @@ export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string + stepsExpanded?: boolean + onStepsExpandedChange?: (expanded: boolean) => void classes?: { root?: string content?: string @@ -60,11 +62,12 @@ export function SessionTurn( function handleScroll() { if (!scrollRef) return - // prevents scroll loops - if (working() && scrollRef.scrollTop < 100) return - setState("scrollY", scrollRef.scrollTop) if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef + const scrollRoom = scrollHeight - clientHeight + if (scrollRoom > 100) { + setState("scrollY", scrollTop) + } const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { setState("userScrolled", true) @@ -222,11 +225,17 @@ export function SessionTurn( const [store, setStore] = createStore({ status: rawStatus(), - stepsExpanded: true, + stepsExpanded: props.stepsExpanded ?? working(), duration: duration(), }) createEffect(() => { + if (props.stepsExpanded !== undefined) { + setStore("stepsExpanded", props.stepsExpanded) + } + }) + + createEffect(() => { const timer = setInterval(() => { setStore("duration", duration()) }, 1000) @@ -260,8 +269,13 @@ export function SessionTurn( createEffect((prev) => { const isWorking = working() + if (!prev && isWorking) { + setStore("stepsExpanded", true) + props.onStepsExpandedChange?.(true) + } if (prev && !isWorking && !state.userScrolled) { setStore("stepsExpanded", false) + props.onStepsExpandedChange?.(false) } return isWorking }, working()) @@ -278,7 +292,7 @@ export function SessionTurn( <div data-slot="session-turn-message-header"> <div data-slot="session-turn-message-title"> <Switch> - <Match when={working()}> + <Match when={working() && message().id === userMessages().at(-1)?.id}> <Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" /> </Match> <Match when={true}> @@ -298,7 +312,11 @@ export function SessionTurn( data-slot="session-turn-collapsible-trigger-content" variant="ghost" size="small" - onClick={() => setStore("stepsExpanded", !store.stepsExpanded)} + onClick={() => { + const next = !store.stepsExpanded + setStore("stepsExpanded", next) + props.onStepsExpandedChange?.(next) + }} > <Show when={working()}> <Spinner /> diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 265178e10..16efe7779 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,6 +1,6 @@ import type { Message, Session, Part, FileDiff, SessionStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" -import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr" +import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" type Data = { session: Session[] diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index af5da06f9..71fc63806 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -1,79 +1,105 @@ -import { For, Show, type JSX } from "solid-js" -import { createStore } from "solid-js/store" -import { createSimpleContext } from "@opencode-ai/ui/context" +import { + createContext, + createEffect, + createSignal, + getOwner, + Owner, + ParentProps, + runWithOwner, + Show, + useContext, + type JSX, +} from "solid-js" +import { Dialog as Kobalte } from "@kobalte/core/dialog" + +type DialogElement = () => JSX.Element -type DialogElement = JSX.Element | (() => JSX.Element) +const Context = createContext<ReturnType<typeof init>>() -export const { use: useDialog, provider: DialogProvider } = createSimpleContext({ - name: "Dialog", - init: () => { - const [store, setStore] = createStore({ - stack: [] as { +function init() { + const [active, setActive] = createSignal< + | { + id: string element: DialogElement onClose?: () => void - }[], - }) + owner: Owner + } + | undefined + >() - return { - get stack() { - return store.stack - }, - push(element: DialogElement, onClose?: () => void) { - setStore("stack", (s) => [...s, { element, onClose }]) - }, - pop() { - const current = store.stack.at(-1) - current?.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - }, - replace(element: DialogElement, onClose?: () => void) { - for (const item of store.stack) { - item.onClose?.() - } - setStore("stack", [{ element, onClose }]) - }, - clear() { - for (const item of store.stack) { - item.onClose?.() - } - setStore("stack", []) - }, - } - }, -}) + const result = { + get active() { + return active() + }, + close() { + active()?.onClose?.() + setActive(undefined) + }, + show(element: DialogElement, owner: Owner, onClose?: () => void) { + active()?.onClose?.() + const id = Math.random().toString(36).slice(2) + setActive({ + id, + element: () => + runWithOwner(owner, () => ( + <Show when={active()?.id === id}> + <Kobalte + modal + open={true} + onOpenChange={(open) => { + if (!open) { + console.log("closing") + result.close() + } + }} + > + <Kobalte.Portal> + <Kobalte.Overlay data-component="dialog-overlay" /> + {element()} + </Kobalte.Portal> + </Kobalte> + </Show> + )), + onClose, + owner, + }) + }, + } -import { Dialog as Kobalte } from "@kobalte/core/dialog" + return result +} -export function DialogRoot(props: { children?: JSX.Element }) { - const dialog = useDialog() +export function DialogProvider(props: ParentProps) { + const ctx = init() + createEffect(() => { + console.log("active", ctx.active) + }) return ( - <> + <Context.Provider value={ctx}> {props.children} - <Show when={dialog.stack.length > 0}> - <div data-component="dialog-stack"> - <For each={dialog.stack}> - {(item, index) => ( - <Show when={index() === dialog.stack.length - 1}> - <Kobalte - modal - defaultOpen - onOpenChange={(open) => { - if (!open) { - item.onClose?.() - dialog.pop() - } - }} - > - <Kobalte.Portal> - <Kobalte.Overlay data-component="dialog-overlay" /> - {typeof item.element === "function" ? item.element() : item.element} - </Kobalte.Portal> - </Kobalte> - </Show> - )} - </For> - </div> - </Show> - </> + <div data-component="dialog-stack">{ctx.active?.element?.()}</div> + </Context.Provider> ) } + +export function useDialog() { + const ctx = useContext(Context) + const owner = getOwner() + if (!owner) { + throw new Error("useDialog must be used within a DialogProvider") + } + if (!ctx) { + throw new Error("useDialog must be used within a DialogProvider") + } + return { + get active() { + return ctx.active + }, + show(element: DialogElement, onClose?: () => void) { + ctx.show(element, owner, onClose) + }, + close() { + ctx.close() + }, + } +} diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 0d9c44758..f4d85519d 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -2,7 +2,7 @@ import { marked } from "marked" import markedShiki from "marked-shiki" import { bundledLanguages, type BundledLanguage } from "shiki" import { createSimpleContext } from "./helper" -import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/precision-diffs" +import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs" registerCustomTheme("OpenCode", () => { return Promise.resolve({ diff --git a/packages/ui/src/custom-elements.d.ts b/packages/ui/src/custom-elements.d.ts index 6ad3ea34e..b756e51da 100644 --- a/packages/ui/src/custom-elements.d.ts +++ b/packages/ui/src/custom-elements.d.ts @@ -1,12 +1,12 @@ /** - * TypeScript declaration for the <file-diff> custom element. - * This tells TypeScript that <file-diff> is a valid JSX element in SolidJS. - * Required for using the precision-diffs web component in .tsx files. + * TypeScript declaration for the <diffs-container> custom element. + * This tells TypeScript that <diffs-container> is a valid JSX element in SolidJS. + * Required for using the @pierre/diffs web component in .tsx files. */ declare module "solid-js" { namespace JSX { interface IntrinsicElements { - "file-diff": HTMLAttributes<HTMLElement> + "diffs-container": HTMLAttributes<HTMLElement> } } } diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index 8780bc6c5..f83fc82a2 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -1,4 +1,4 @@ -import { DiffLineAnnotation, FileContents, FileDiffOptions } from "@pierre/precision-diffs" +import { DiffLineAnnotation, FileContents, FileDiffOptions } from "@pierre/diffs" import { ComponentProps } from "solid-js" export type DiffProps<T = {}> = FileDiffOptions<T> & { @@ -10,8 +10,8 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & { } const unsafeCSS = ` -[data-pjs-header], -[data-pjs] { +[data-diffs-header], +[data-diffs] { [data-separator-wrapper] { margin: 0 !important; border-radius: 0 !important; @@ -71,12 +71,12 @@ export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) } export const styleVariables = { - "--pjs-font-family": "var(--font-family-mono)", - "--pjs-font-size": "var(--font-size-small)", - "--pjs-line-height": "24px", - "--pjs-tab-size": 2, - "--pjs-font-features": "var(--font-family-mono--font-feature-settings)", - "--pjs-header-font-family": "var(--font-family-sans)", - "--pjs-gap-block": 0, - "--pjs-min-number-column-width": "4ch", + "--diffs-font-family": "var(--font-family-mono)", + "--diffs-font-size": "var(--font-size-small)", + "--diffs-line-height": "24px", + "--diffs-tab-size": 2, + "--diffs-font-features": "var(--font-family-mono--font-feature-settings)", + "--diffs-header-font-family": "var(--font-family-sans)", + "--diffs-gap-block": 0, + "--diffs-min-number-column-width": "4ch", } diff --git a/packages/ui/src/pierre/worker.ts b/packages/ui/src/pierre/worker.ts index 2b2da1f09..e47268d4e 100644 --- a/packages/ui/src/pierre/worker.ts +++ b/packages/ui/src/pierre/worker.ts @@ -1,5 +1,5 @@ -import { getOrCreateWorkerPoolSingleton } from "@pierre/precision-diffs/worker" -import ShikiWorkerUrl from "@pierre/precision-diffs/worker/worker.js?worker&url" +import { getOrCreateWorkerPoolSingleton } from "@pierre/diffs/worker" +import ShikiWorkerUrl from "@pierre/diffs/worker/worker.js?worker&url" export function workerFactory(): Worker { return new Worker(ShikiWorkerUrl, { type: "module" }) diff --git a/packages/util/package.json b/packages/util/package.json index c77d99e7c..02c01dd24 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.0.153", + "version": "1.0.162", "private": true, "type": "module", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 9d028edbb..2ae1be84b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "1.0.153", + "version": "1.0.162", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 64e11ff56..777d377a7 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -161,13 +161,32 @@ opencode github run List all available models from configured providers. ```bash -opencode models +opencode models [provider] ``` This command displays all models available across your configured providers in the format `provider/model`. This is useful for figuring out the exact model name to use in [your config](/docs/config/). +You can optionally pass a provider ID to filter models by that provider. + +```bash +opencode models anthropic +``` + +#### Flags + +| Flag | Description | +| ----------- | ------------------------------------------------------------ | +| `--refresh` | Refresh the models cache from models.dev | +| `--verbose` | Use more verbose model output (includes metadata like costs) | + +Use the `--refresh` flag to update the cached model list. This is useful when new models have been added to a provider and you want to see them in OpenCode. + +```bash +opencode models --refresh +``` + --- ### run diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 82ea391e8..302d79d17 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -367,6 +367,25 @@ The `disabled_providers` option accepts an array of provider IDs. When a provide --- +### Enabled providers + +You can specify an allowlist of providers through the `enabled_providers` option. When set, only the specified providers will be enabled and all others will be ignored. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "enabled_providers": ["anthropic", "openai"] +} +``` + +This is useful when you want to restrict OpenCode to only use specific providers rather than disabling them one by one. + +:::note +If a provider appears in both `enabled_providers` and `disabled_providers`, the `disabled_providers` takes priority for backwards compatibility. +::: + +--- + ## Variables You can use variable substitution in your config files to reference environment variables and file contents. diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index c62f12cb3..8f75f5aeb 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -27,7 +27,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | | [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style | | [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. | -| [opencode-wakatime](https://github.com/angrister/opencode-wakatime) | Track OpenCode usage with Wakatime | +| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | | [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | | [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | @@ -35,12 +35,12 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Projects -| Name | Description | -| --------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| kimaki (https://github.com/remorses/kimaki) | Discord bot to control OpenCode sessions, built on the SDK | -| opencode.nvim (https://github.com/NickvanDyke/opencode.nvim) | Neovim plugin for editor-aware prompts, built on the API | -| portal (https://github.com/hosenur/portal) | Mobile-first web UI for OpenCode over Tailscale/VPN | -| opencode plugin template (https://github.com/zenobi-us/opencode-plugin-template/) | Template for building OpenCode plugins | +| Name | Description | +| ---------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| [kimaki](https://github.com/remorses/kimaki) | Discord bot to control OpenCode sessions, built on the SDK | +| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Neovim plugin for editor-aware prompts, built on the API | +| [portal](https://github.com/hosenur/portal) | Mobile-first web UI for OpenCode over Tailscale/VPN | +| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | Template for building OpenCode plugins | --- diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index 236b4db82..ad2085d81 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -22,6 +22,7 @@ OpenCode comes with several built-in LSP servers for popular languages: | elixir-ls | .ex, .exs | `elixir` command available | | zls | .zig, .zon | `zig` command available | | csharp | .cs | `.NET SDK` installed | +| fsharp | .fs, .fsi, .fsx, .fsscript | `.NET SDK` installed | | vue | .vue | Auto-installs for Vue projects | | rust | .rs | `rust-analyzer` command available | | clangd | .c, .cpp, .cc, .cxx, .c++, .h, .hpp, .hh, .hxx, .h++ | Auto-installs for C/C++ projects | diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 6291f7177..2611adef0 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.0.153", + "version": "1.0.162", "publisher": "sst-dev", "repository": { "type": "git", |
