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