Merge branch 'dev' of https://github.com/sst/opencode into dev

This commit is contained in:
David Hill 2025-12-16 10:54:51 +00:00
commit 05e0759878
116 changed files with 3023 additions and 1164 deletions

View file

@ -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
});
}

View file

@ -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 }}

34
.github/workflows/triage.yml vendored Normal file
View file

@ -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"

12
.opencode/agent/triage.md Normal file
View file

@ -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.

49
.opencode/bun.lock Normal file
View file

@ -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/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
"@octokit/core": ["@octokit/core@7.0.6", "", { "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/endpoint@11.0.2", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ=="],
"@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "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/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="],
"@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="],
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="],
"@octokit/request": ["@octokit/request@10.0.7", "", { "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/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"@octokit/rest": ["@octokit/rest@22.0.1", "", { "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/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@0.0.0-dev-202512160508", "", { "dependencies": { "@opencode-ai/sdk": "0.0.0-dev-202512160508", "zod": "4.1.8" } }, "sha512-GLnvMQhEWRHG9E84FyyQKPKi54bGUkytXPfZYjwNy9W6djw8zAW/kpeYPrdIJHPdTHk4OjIHEwoB1SXZzGaLFQ=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@0.0.0-dev-202512160508", "", {}, "sha512-ICpZ1bX528yQKqYGGyUJQMu3RY0F1pQ6RCoTJ4ESLiYmcXUY1EldgIidiwPA+A/zpEXLu2lPwPZ1LYn/bX6aFA=="],
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
}
}

4
.opencode/env.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.txt" {
const content: string
export default content
}

View file

@ -11,4 +11,7 @@
},
},
"mcp": {},
"tools": {
"github-triage": false,
},
}

6
.opencode/package.json Normal file
View file

@ -0,0 +1,6 @@
{
"dependencies": {
"@octokit/rest": "^22.0.1",
"@opencode-ai/plugin": "0.0.0-dev-202512160508"
}
}

View file

@ -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")
},
})

View file

@ -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

View file

@ -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/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.6.1", "", { "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/diffs@1.0.0-beta.3", "", { "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/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
@ -4112,11 +4113,13 @@
"@parcel/watcher/node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.15.0", "", { "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/core@3.19.0", "", { "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/transformers@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/types": "3.15.0" } }, "sha512-Hmwip5ovvSkg+Kc41JTvSHHVfCYF+C8Cp1omb5AJj4Xvd+y9IXz2rKJwmFRGsuN0vpHxywcXJ1+Y4B9S7EG1/A=="],
"@pierre/diffs/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.19.0", "", { "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": ["shiki@3.15.0", "", { "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/transformers@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/types": "3.19.0" } }, "sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw=="],
"@pierre/diffs/shiki": ["shiki@3.19.0", "", { "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": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
@ -4670,19 +4673,19 @@
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.15.0", "", { "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/types@3.19.0", "", { "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/types@3.15.0", "", { "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/types@3.19.0", "", { "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/engine-javascript@3.15.0", "", { "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/types@3.19.0", "", { "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/engine-oniguruma@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA=="],
"@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.19.0", "", { "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/langs@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A=="],
"@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg=="],
"@pierre/precision-diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="],
"@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A=="],
"@pierre/precision-diffs/shiki/@shikijs/types": ["@shikijs/types@3.15.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="],
"@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],

6
flake.lock generated
View file

@ -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": {

View file

@ -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:*"
}
}

17
install
View file

@ -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

View file

@ -1,3 +1,3 @@
{
"nodeModules": "sha256-lgPsYtNJT7a+mDk5cTiEJLlBnTMTjxZCl8bw5WxcuaM="
"nodeModules": "sha256-PyoVOza+3WnwZbtpPF6uSN1zkyLsSG2VsgBfIMvIFAs="
}

View file

@ -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",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.153",
"version": "1.0.162",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",

View file

@ -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">

View file

@ -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

View file

@ -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>

View file

@ -206,6 +206,7 @@ body {
[data-component="top"] {
padding: 24px var(--padding);
height: 80px;
min-height: 80px;
position: sticky;
top: 0;
display: flex;

View file

@ -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>

View file

@ -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": {

View file

@ -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",

View file

@ -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",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.153",
"version": "1.0.162",
"description": "",
"type": "module",
"exports": {

View file

@ -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>
)
}

View file

@ -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()

View file

@ -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) => (

View file

@ -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

View file

@ -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>

View file

@ -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) => (

View file

@ -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 attachments = currentPrompt.filter(
(part) => part.type === "file",
) as import("@/context/prompt").FileAttachmentPart[]
// 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 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>

View file

@ -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"> {

View file

@ -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()
},
}
},
})

View file

@ -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) => {

View file

@ -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])
}),
)
},
}
},
}
},
})

View file

@ -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
}
})

View file

@ -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

View file

@ -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)
})
},
}
},
})

View file

@ -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)
}

View file

@ -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])
}),
)
},
}
},
})

View file

@ -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()) {

View file

@ -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>
)
})}

View file

@ -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>

View file

@ -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,28 +27,324 @@ 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"
createEffect(() => {
if (!params.id) return
sync.session.sync(params.id)
})
createEffect(() => {
if (layout.terminal.opened()) {
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 ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return
if (dialog.active) return
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()
return
}
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
inputRef?.focus()
}
}
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
@ -58,82 +354,6 @@ export default function Page() {
document.removeEventListener("keydown", handleKeyDown)
})
createEffect(() => {
if (layout.terminal.opened()) {
if (session.terminal.all().length === 0) {
session.terminal.new()
}
}
})
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
}
// @ts-expect-error
if (document.activeElement?.dataset?.component === "terminal") {
return
}
const focused = document.activeElement === inputRef
if (focused) {
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()
}
}
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}

View file

@ -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
}

View file

@ -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:",

View file

@ -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"

View file

@ -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"]

View file

@ -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"
}

View file

@ -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",

View file

@ -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)

View file

@ -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()

View file

@ -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) => {

View file

@ -297,6 +297,24 @@ function App() {
local.model.cycle(-1)
},
},
{
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",

View file

@ -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)
})

View file

@ -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">

View file

@ -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

View file

@ -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)
})

View file

@ -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>

View file

@ -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()

View file

@ -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?.()
})
},

View file

@ -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()

View file

@ -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)
}

View file

@ -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()

View file

@ -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),

View file

@ -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,

View file

@ -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 = {

View file

@ -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
}

View file

@ -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()
}
}
}

View file

@ -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)

View file

@ -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",

View file

@ -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"?: (

View file

@ -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",

View file

@ -917,6 +917,14 @@ export type KeybindsConfig = {
* Previous recently used model
*/
model_cycle_recent_reverse?: string
/**
* Next favorite model
*/
model_cycle_favorite?: string
/**
* Previous favorite model
*/
model_cycle_favorite_reverse?: string
/**
* List available commands
*/
@ -1413,6 +1421,9 @@ export type Config = {
build?: AgentConfig
general?: AgentConfig
explore?: AgentConfig
title?: AgentConfig
summary?: AgentConfig
compaction?: AgentConfig
[key: string]: AgentConfig | undefined
}
/**

View file

@ -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": {

View file

@ -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",

View file

@ -1,7 +1,7 @@
{
"name": "@opencode-ai/tauri",
"private": true,
"version": "1.0.153",
"version": "1.0.162",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",

View file

@ -1,4 +1,3 @@
import * as fs from "node:fs/promises"
import { $ } from "bun"
import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils"

View file

@ -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__ ??= {{}};

View file

@ -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/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
"externalBin": ["sidecars/opencode-cli"],
"createUpdaterArtifacts": true,

View file

@ -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 (

View file

@ -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()
}

View file

@ -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 {

View file

@ -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",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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"

View file

@ -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">

View file

@ -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>
)
}

View file

@ -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;

View file

@ -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"

View file

@ -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"> {

View file

@ -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);

View file

@ -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) => {

View file

@ -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;

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