Compare commits

..

121 Commits

Author SHA1 Message Date
opencode 4d187af9d2 release: v1.1.2 2026-01-05 07:16:26 +00:00
Aiden Cline 7e14cc687a ci: fix OPENCODE_PERMISSION env vars 2026-01-05 00:40:34 -06:00
Dax Raad 2f5b2b23d5 core: fix permission rule matching to use permission field instead of pattern field 2026-01-05 01:21:49 -05:00
Aiden Cline 035baa4b38 ignore: add codeowners file for adam 2026-01-05 00:17:32 -06:00
Dax Raad 9f38af44db core: fix permission evaluation to use rule-based matching instead of wildcard patterns 2026-01-05 01:07:03 -05:00
Rafi Khardalian 7324b2260a fix(tui): allow exit when viewing child session (#6898)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-01-04 23:14:43 -06:00
GitHub Action 166f169dbf chore: generate 2026-01-05 03:47:12 +00:00
Frank 9c55cb729b zen: add index 2026-01-04 22:46:21 -05:00
Aiden Cline f2e65e40ea fix: handle skill scan failures for .claude gracefully 2026-01-04 21:39:45 -06:00
Aiden Cline 8b3ae08a55 acp: handle case where big-pickle is unavailable as a fallback 2026-01-04 21:10:30 -06:00
Aiden Cline 555d7fcdde ci: make sure opencode is installed 2026-01-04 20:35:41 -06:00
opencode-agent[bot] 2410a6bc9e Fix symmetric padding in TUI input field (#6894)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-01-04 20:34:14 -06:00
GitHub Action 59ed8ccbd8 chore: generate 2026-01-05 02:18:20 +00:00
OpeOginni 91ed101378 feat(desktop): implement auto-scroll for active command in slash popover (#6797) 2026-01-04 20:17:47 -06:00
Daniel Polito fb60f9c396 Desktop: Fix Responsive Menu (#6789) 2026-01-04 18:47:14 -06:00
Shkumbin Hasani e93699b741 perf: optimize model dialog visibility lookups (#6791) 2026-01-04 18:46:23 -06:00
Daniel Polito 9ac00f55bc Desktop: Adding Home Icon on Responsive Menu (#6794) 2026-01-04 18:44:28 -06:00
Daniel Polito 393cf78ca6 Desktop: Improve Big Session Navigation - Scrollable (#6837) 2026-01-04 18:40:58 -06:00
GitHub Action 478fec61ab chore: generate 2026-01-05 00:39:48 +00:00
shuv 52ad134d55 feat(app): add SVG preview support in session viewer (#6868) 2026-01-04 18:39:15 -06:00
NN708 3e09abbfda feat(desktop): add AppStream MetaInfo file (#6030) 2026-01-04 18:36:07 -06:00
Dax Raad 5450644c67 docs: restructure permissions documentation to clarify v1.1.1 changes and action-based model 2026-01-04 19:35:04 -05:00
Carter McBride 0c2ccf25dc Fix a few mobile screen size issues (#6808) 2026-01-04 18:32:48 -06:00
Ravi Kumar 65c7168492 fix(app): fix custom slash commands not showing on initial / (#6829) 2026-01-04 18:30:34 -06:00
Albin Groen c74c66e6b4 fix(ui): fix select chevron alignment (#6690) 2026-01-04 18:29:19 -06:00
Aiden Cline c545fa2a28 ci: nix desktop 2026-01-04 13:52:32 -06:00
Aiden Cline 80235f325e ci: fix dup pr action 2026-01-04 13:30:58 -06:00
Rohan Godha 88c306efd2 fix: prevent session list rows from wrapping to 2 lines (#6812) 2026-01-04 13:29:44 -06:00
Melih Mucuk 554572bc39 fix: prevent main model thinking variant from applying to small model (#6839)
Co-authored-by: Melih Mucuk <melih@monkeysteam.com>
2026-01-04 13:28:22 -06:00
Aiden Cline e5abe1e78b tweak: bump default to 30 seconds (lots of people complained about 5...) 2026-01-04 13:26:43 -06:00
Aiden Cline 1d54f90330 docs: add instructions for running web and desktop apps during development 2026-01-04 13:12:43 -06:00
Dax Raad 5f10243e91 tui: fix session configuration merge conflict resolution 2026-01-04 13:43:33 -05:00
Dax Raad 226a5c2000 tui: fix optional session access to prevent runtime errors 2026-01-04 13:43:33 -05:00
Github Action f8442ad016 Update Nix flake.lock and hashes 2026-01-04 18:39:44 +00:00
GitHub Action 1e28d10610 chore: generate 2026-01-04 18:39:08 +00:00
Dax Raad 7304ba616e tui: add session search functionality with debounced input and server-side filtering 2026-01-04 13:38:30 -05:00
Dax Raad cdd6ea514b core: improve Rust formatter detection and add cargo fmt support 2026-01-04 13:04:28 -05:00
GitHub Action 24d9c1d18d chore: generate 2026-01-04 17:09:30 +00:00
Adam 5ca2f6c5a9 fix(app): prompt input improvements 2026-01-04 11:08:47 -06:00
Adam 12ffb270fb fix(app): prompt input improvements 2026-01-04 10:37:56 -06:00
opencode dc25669b6e release: v1.1.1 2026-01-04 15:52:55 +00:00
Github Action 0f9130b649 Update Nix flake.lock and hashes 2026-01-04 15:39:15 +00:00
Dax Raad a76570b5dd tui: add development scripts for better debugging workflow 2026-01-04 10:38:02 -05:00
Dax Raad 97977f6ad4 ensure @opencode-ai/plugin exists only on first run 2026-01-04 10:23:42 -05:00
GitHub Action 555a5ccb59 chore: generate 2026-01-04 15:13:52 +00:00
Adam Spiers 24dedb4f7b fix(tui): add missing theme_list keybind (#6779)
Co-authored-by: Adam Spiers <opencode@adamspiers.org>
2026-01-04 09:13:19 -06:00
Paolo Ricciuti 21dc3c24d9 feat: mcp resources (#6542) 2026-01-04 09:12:54 -06:00
Jérôme Benoit e00621cb17 feat(nix): preliminary desktop app flake integration (#6135)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-04 09:11:05 -06:00
Dax Raad 2d074f0472 initialize config in worktree 2026-01-04 10:10:25 -05:00
Felipe Orlando f3cd3b8941 Remove opencode-skills entry from ecosystem.mdx (#6817) 2026-01-04 08:43:36 -06:00
John Connor 1f8dab50be docs: typo in subtask documentation (#6821) 2026-01-04 08:43:03 -06:00
Aiden Cline 29672e7b95 ci: update duplicate pr action 2026-01-04 08:36:21 -06:00
GitHub Action 4f3ac709a4 chore: generate 2026-01-04 14:22:48 +00:00
Matt Silverlock 8aa56dc01d docs: add logging best practices for plugin authors (#6833) 2026-01-04 08:22:14 -06:00
Aiden Cline d72d7ab510 tweak: prioritize free gpt-5-mini for small model in github copilot 2026-01-04 08:21:09 -06:00
Adam 5053822bd6 fix(app): auto-scroll 2026-01-04 06:14:24 -06:00
Adam 177b01a853 fix(app): scroll position restoration 2026-01-04 04:53:55 -06:00
Adam c9f907caec fix(app): don't override ctrl+a on windows 2026-01-04 04:35:26 -06:00
Adam 7ce0520f8d fix(app): auto-scroll behaviors 2026-01-04 04:24:37 -06:00
Matt Silverlock 4486174e43 github: handle duplicate PR creation when agent creates PR (#6777) 2026-01-04 02:05:08 -06:00
Aiden Cline 41cf45a16e tui: fix system theme diff highlighting
- Generate distinct red/green backgrounds for added/removed lines in system theme
- Use bright ANSI colors for diff highlights to improve visibility
- Fix ANSI palette indexing to handle null entries safely
- Add color tinting to create proper diff backgrounds while respecting terminal colors

Resolves issue where system theme showed no red/green diff highlighting
2026-01-04 02:01:02 -06:00
Aiden Cline 3611260405 core: remove hardcoded .env read block and use new permissions model instead 2026-01-04 01:49:49 -06:00
Shpetim c3fd3c8656 fix(plugin): prevent duplicate plugin function initialization (#6787)
Co-authored-by: Shpetim <shpetim.alimi@ndbit.net>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-01-04 01:39:54 -06:00
ben 4d7d28c30a docs: Add opencode-scheduler plugin to ecosystem (#6804)
Co-authored-by: Benjamin Shafii <benjaminshafii@home-server.local>
2026-01-04 01:08:14 -06:00
Aiden Cline 96a00ffea9 core: update github copilot model model priority list 2026-01-04 00:57:35 -06:00
Aiden Cline 02540b2464 ignore: update sst -> anomalyco 2026-01-04 00:30:03 -06:00
Aiden Cline 5aa4fd0042 core: add variant to chat.message input 2026-01-04 00:28:52 -06:00
Aiden Cline b934c22d8d ci: add duplicate PR detection bot 2026-01-04 00:15:59 -06:00
shuv 72cef0d9e7 feat: add --variant flag to run command (#6805)
Co-authored-by: shuv <shuv@shuv.dev>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-01-04 00:08:34 -06:00
Frank d3fd6d1a10 zen: update models 2026-01-04 00:58:06 -05:00
jerilynzheng 6b12a0084c docs: Add Vercel AI Gateway to provider docs (#6790) 2026-01-03 20:57:26 -06:00
GitHub Action a5a19197f5 chore: generate 2026-01-04 02:57:20 +00:00
Saatvik Arya 74d0d2b942 docs: update AGENTS.md (#6800) 2026-01-03 20:56:47 -06:00
Adam 235837d2d9 fix(app): diff rendering performance 2026-01-03 19:57:59 -06:00
Dax Raad dcf37000e4 tui: remove openrouter provider from priority list 2026-01-03 20:45:15 -05:00
Dax Raad 5944443a60 core: fix dependency installation and git worktree branch creation 2026-01-03 20:22:19 -05:00
Dax Raad 81e8d29ad2 oops 2026-01-03 19:25:59 -05:00
GitHub Action 8b6cf7081f chore: generate 2026-01-03 23:53:29 +00:00
Dax Raad 0b4af95223 core: add sandbox support for git worktrees to allow working in multiple directories per project 2026-01-03 18:52:53 -05:00
Mani Sundararajan f6cc84747a fix(tui): make lsp status icon muted when no lsps are active (#6773) 2026-01-03 14:56:29 -06:00
Rhys Sullivan 586e7347bd fix(mcp): add timeout to client.connect() calls (#6760) 2026-01-03 11:54:24 -06:00
Osinachi Okpara 69d4ef038b docs: enhance MCP servers documentation with a tip (#6713) 2026-01-03 11:02:05 -06:00
Daniel Polito c7c1790da8 Desktop: Edit Project Fix (#6757) 2026-01-03 10:26:30 -06:00
Mani Sundararajan 12eea69f2e fix(tui): make mcp status icon muted when no mcp servers are enabled (#6745) 2026-01-03 10:23:09 -06:00
OpeOginni 308e8060dc fix(server): update server URL normalization to retain path (#6647) 2026-01-03 09:50:15 -06:00
shuv 5f93beed77 feat(app): add image preview support in session viewer (#6678) 2026-01-03 05:46:42 -06:00
GitHub Action 527553ada2 chore: generate 2026-01-03 07:16:11 +00:00
Jake Nelson 5c5e636030 feat: add per-project MCP config overrides (#5406)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-03 01:15:37 -06:00
Gabriel Patzleiner da6df3d432 fix(kotlin-ls): improve root detection for Gradle multi-project builds (#6717) 2026-01-03 01:14:01 -06:00
Aiden Cline b9b0e3475c core: improve plugin loading to handle builtin plugin failures gracefully (#6739) 2026-01-03 00:54:35 -06:00
GitHub Action 77fcefca0e chore: generate 2026-01-03 06:35:01 +00:00
Dax Raad 47c670aea9 tui: add reject message support to permission dialogs for better user feedback 2026-01-03 01:34:23 -05:00
Aiden Cline 2b66b31d96 ignore: update bug report template 2026-01-03 00:20:43 -06:00
Aiden Cline f991fbbde8 core: ephemerally wrap queued user messages with reminder to stay on track (#6725) 2026-01-02 22:42:56 -06:00
shuv 401b498c7d fix(tui): pass attach directory to sdk client (#6715)
Co-authored-by: shuv <shuv@shuv.dev>
2026-01-02 21:54:11 -06:00
opencode-agent[bot] f2ec036027 docs: rm incorrect -p alias from docs (#6721)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-01-02 21:11:42 -06:00
GitHub Action a235aec9ab chore: generate 2026-01-03 02:17:34 +00:00
Adam 052de3c556 feat: add managed git worktrees (#6674) 2026-01-02 20:17:02 -06:00
Github Action f6fe709f6e Update Nix flake.lock and hashes 2026-01-03 00:10:15 +00:00
Sebastian Herrlinger ff0bd84870 upgrade opentui to v0.1.68, using gpa 2026-01-03 01:08:58 +01:00
Dax Raad b4af8a65ec ci 2026-01-02 18:58:56 -05:00
Dax Raad 49c5c2b1df ci 2026-01-02 18:56:41 -05:00
Dax Raad 4956ee3ebd tui: add escape key handling to permission dialogs for better keyboard navigation 2026-01-02 18:48:26 -05:00
GitHub Action 1261b7d333 chore: generate 2026-01-02 22:58:02 +00:00
YeonGyu-Kim a3f38e0533 feat(plugin): add tui.session.select API endpoint for TUI navigation (#6565)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-02 16:57:21 -06:00
GitHub Action 681a257df6 chore: generate 2026-01-02 22:46:22 +00:00
Troy Gaines 586207adb4 feat: Add kotlin lsp integration (#6601) 2026-01-02 16:45:44 -06:00
theavgjojo a58dbb3b5c chore: add license field to package.json (#6693)
Co-authored-by: theavgjojo <jojo@noreply>
2026-01-02 16:29:09 -06:00
Spoon 131d8e5778 docs: add subtask2 to ecosystem page (#6704) 2026-01-02 16:26:06 -06:00
Dax Raad 0cf0294787 anomalyco/opencode 2026-01-02 16:09:06 -05:00
Dax 3c41e4e8f1 chore: rename repo references from sst/opencode to anomalyco/opencode (#6687)
Co-authored-by: Github Action <action@github.com>
2026-01-02 16:02:52 -05:00
Matt Silverlock 66bc046503 fix: merge instructions arrays across config files (#6663) 2026-01-02 13:55:40 -06:00
Matt Silverlock 6e68ea034c fix: handle actions/checkout v6 credential storage change (#6667) 2026-01-02 13:54:43 -06:00
Adam c51fa7cb24 feat: add /compact session command 2026-01-02 12:40:37 -06:00
Adam a4c67515c9 fix(desktop): tweak window drag region layout 2026-01-02 12:40:36 -06:00
Adam 1d2d710fce fix: relax request timeouts 2026-01-02 12:40:36 -06:00
Aiden Cline 2fd97377f6 test: fix transform test 2026-01-02 12:38:44 -06:00
Aiden Cline 47ebb2973f test: add message-v2 test 2026-01-02 12:28:40 -06:00
Aiden Cline 49d7ccd1db fix: variant for minimal 2026-01-02 12:28:40 -06:00
Aiden Cline c996f3d847 chore: ensure empty message isnt sent 2026-01-02 12:28:40 -06:00
Mike English 70881b2937 fix: cloudflare-ai-gateway sdk.chat undefined error (#6407) 2026-01-02 11:24:13 -06:00
176 changed files with 5646 additions and 2056 deletions
+4
View File
@@ -0,0 +1,4 @@
# web + desktop packages
packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin
packages/desktop/ @adamdotdevin
+8
View File
@@ -11,6 +11,14 @@ body:
validations:
required: true
- type: input
id: plugins
attributes:
label: Plugins
description: What plugins are you using?
validations:
required: false
- type: input
id: opencode-version
attributes:
+2 -2
View File
@@ -28,8 +28,8 @@ jobs:
OPENCODE_PERMISSION: |
{
"bash": {
"gh issue*": "allow",
"*": "deny"
"*": "deny",
"gh issue*": "allow"
},
"webfetch": "deny"
}
+63
View File
@@ -0,0 +1,63 @@
name: Duplicate PR Check
on:
pull_request_target:
types: [opened]
jobs:
check-duplicates:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install dependencies
run: bun install
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Build prompt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
{
echo "Check for duplicate PRs related to this new PR:"
echo ""
echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)"
echo ""
echo "Description:"
gh pr view "$PR_NUMBER" --json body --jq .body
} > pr_info.txt
- name: Check for duplicate PRs
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
$COMMENT"
+35
View File
@@ -0,0 +1,35 @@
name: nix desktop
on:
push:
branches: [dev]
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
workflow_dispatch:
jobs:
build-desktop:
strategy:
fail-fast: false
matrix:
os:
- blacksmith-4vcpu-ubuntu-2404
- macos-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: DeterminateSystems/nix-installer-action@v21
- name: Build desktop via flake
run: |
set -euo pipefail
nix --version
nix build .#desktop -L
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Run opencode
uses: sst/opencode/github@latest
uses: anomalyco/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_PERMISSION: '{"bash": "deny"}'
+1 -1
View File
@@ -31,7 +31,7 @@ permissions:
jobs:
publish:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'sst/opencode'
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@v3
with:
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "gh pr review*": "deny", "*": "deny" } }'
OPENCODE_PERMISSION: '{ "bash": { "*": "deny", "gh*": "allow", "gh pr review*": "deny" } }'
PR_TITLE: ${{ steps.pr-details.outputs.title }}
run: |
PR_BODY=$(jq -r .body pr_data.json)
+24
View File
@@ -0,0 +1,24 @@
---
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
color: "#E67E22"
tools:
"*": false
"github-pr-search": true
---
You are a duplicate PR detection agent. When a PR is opened, your job is to search for potentially duplicate or related open PRs.
Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature.
Search using keywords from the PR title and description. Try multiple searches with different relevant terms.
If you find potential duplicates:
- List them with their titles and URLs
- Briefly explain why they might be related
If no duplicates are found, say so clearly.
Keep your response concise and actionable.
+1 -1
View File
@@ -3,7 +3,7 @@ description: "find issue(s) on github"
model: opencode/claude-haiku-4-5
---
Search through existing issues in sst/opencode using the gh cli to find issues matching this query:
Search through existing issues in anomalyco/opencode using the gh cli to find issues matching this query:
$ARGUMENTS
+1 -5
View File
@@ -10,11 +10,6 @@
"options": {},
},
},
"permission": {
"bash": {
"ls foo": "ask",
},
},
"mcp": {
"context7": {
"type": "remote",
@@ -23,5 +18,6 @@
},
"tools": {
"github-triage": false,
"github-pr-search": false,
},
}
+52
View File
@@ -0,0 +1,52 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
...options.headers,
},
})
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
interface PR {
title: string
html_url: string
}
export default tool({
description: DESCRIPTION,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
offset: tool.schema.number().describe("Number of results to skip for pagination").default(0),
},
async execute(args) {
const owner = "anomalyco"
const repo = "opencode"
const page = Math.floor(args.offset / args.limit) + 1
const searchQuery = encodeURIComponent(`${args.query} repo:${owner}/${repo} type:pr state:open`)
const result = await githubFetch(
`/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`,
)
if (result.total_count === 0) {
return `No PRs found matching "${args.query}"`
}
const prs = result.items as PR[]
const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n")
return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
},
})
+10
View File
@@ -0,0 +1,10 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
+1 -1
View File
@@ -40,7 +40,7 @@ export default tool({
async execute(args) {
const issue = getIssueNumber()
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
const owner = "sst"
const owner = "anomalyco"
const repo = "opencode"
const results: string[] = []
+27 -4
View File
@@ -14,10 +14,10 @@ However, any UI or core product feature must go through a design review with the
If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with any of the following labels:
- [`help wanted`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
- [`good first issue`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
- [`bug`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
- [`perf`](https://github.com/sst/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
- [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
- [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
- [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
- [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
> [!NOTE]
> PRs that ignore these guardrails will likely be closed.
@@ -67,8 +67,31 @@ Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
- Core pieces:
- `packages/opencode`: OpenCode core business logic & server.
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
- `packages/app`: The shared web UI components, written in SolidJS
- `packages/desktop`: The native desktop app, built with Tauri (wraps `packages/app`)
- `packages/plugin`: Source for `@opencode-ai/plugin`
### Running the Web App
To test UI changes during development, run the web app:
```bash
bun run --cwd packages/app dev
```
This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here.
### Running the Desktop App
The desktop app is a native Tauri application that wraps the web UI. To run it:
```bash
bun run --cwd packages/desktop dev
```
> [!NOTE]
> Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
> [!NOTE]
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.
+3 -3
View File
@@ -11,7 +11,7 @@
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -31,7 +31,7 @@ choco install opencode # Windows
brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
```
> [!TIP]
@@ -39,7 +39,7 @@ nix run nixpkgs#opencode # or github:sst/opencode for latest dev branc
### Desktop App (BETA)
OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/sst/opencode/releases) or [opencode.ai/download](https://opencode.ai/download).
OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/anomalyco/opencode/releases) or [opencode.ai/download](https://opencode.ai/download).
| Platform | Download |
| --------------------- | ------------------------------------- |
+4 -4
View File
@@ -11,7 +11,7 @@
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -30,8 +30,8 @@ scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS 與 Linux
paru -S opencode-bin # Arch Linux
mise use -g github:sst/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:sst/opencode 以取得最新開發分支
mise use -g github:anomalyco/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
```
> [!TIP]
@@ -39,7 +39,7 @@ nix run nixpkgs#opencode # 或使用 github:sst/opencode 以取得最
### 桌面應用程式 (BETA)
OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/sst/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。
OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。
| 平台 | 下載連結 |
| --------------------- | ------------------------------------- |
+28 -25
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -173,7 +173,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:",
@@ -201,7 +201,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -230,7 +230,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -246,7 +246,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.224",
"version": "1.1.2",
"bin": {
"opencode": "./bin/opencode",
},
@@ -285,11 +285,12 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.67",
"@opentui/solid": "0.1.67",
"@opentui/core": "0.1.68",
"@opentui/solid": "0.1.68",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
@@ -348,7 +349,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -368,7 +369,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.224",
"version": "1.1.2",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -379,7 +380,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -392,7 +393,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -430,7 +431,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"zod": "catalog:",
},
@@ -441,7 +442,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1196,21 +1197,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.67", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.67", "@opentui/core-darwin-x64": "0.1.67", "@opentui/core-linux-arm64": "0.1.67", "@opentui/core-linux-x64": "0.1.67", "@opentui/core-win32-arm64": "0.1.67", "@opentui/core-win32-x64": "0.1.67", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-zmfyA10QUbzT6ohacPoHmGiYzuJrDSCfQWRWrKtao0BrHj9bii73qWy3V/eR4ibVueoRREwxJs5GlBOSvK6IoA=="],
"@opentui/core": ["@opentui/core@0.1.68", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.68", "@opentui/core-darwin-x64": "0.1.68", "@opentui/core-linux-arm64": "0.1.68", "@opentui/core-linux-x64": "0.1.68", "@opentui/core-win32-arm64": "0.1.68", "@opentui/core-win32-x64": "0.1.68", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-SZz5qNO+2lJ8jDEoTSieyXH23t49myu6NetLex+xzqOf67XsU6QKlDcw5oMmc3zrKvETXhgbBvlSnbyJNQoBMg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.67", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LtOcTlFD+kO7neItmkiF77H8cnjTYzBOZe8JQGwRSt9aaCke3UzMvLxmQnj4BP/kPC3hi9V6NRnFdptz0sJZIQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.68", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ipPX2gavBLVtw3d8L4ZPJDLlEwIjIRNdlNlxu07rqSEGSfxD5s29yc+33wLAlYXbmnJDajOqm0Dx6HnlY1Y9Fg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.67", "", { "os": "darwin", "cpu": "x64" }, "sha512-9i+awVWgpEVqZhFLaLq8usNGyCiyT5QxMLy6eH7JmRic79S34u23HfxiniGRtdYh3aqpm9SbLzo60v0nRIUkCA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.68", "", { "os": "darwin", "cpu": "x64" }, "sha512-9dW0S9HINnuVjvC9QLj+S+329H7qEBQQtyJ9WHpykemokiJ5k4rnuDkfws5FxgTHIf/ddoYYTyPoGCS7WN5gsQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.67", "", { "os": "linux", "cpu": "arm64" }, "sha512-WLjnTM3Ig//SRo0FUZYZJ5TITVbR6dKDVg6axU2D+sMoUzJMBP/Xo04q/TvZ3wP764Yca9l7oVMKWDxHlygyjQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.68", "", { "os": "linux", "cpu": "arm64" }, "sha512-/el6TbSQriBUfPhIa6SBfCCc7tjU98Bnhf2+w0zKwQFBjf3F3kmnI42++YxedMGFmL7bRt3EUawGOkQRZZzFAg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.67", "", { "os": "linux", "cpu": "x64" }, "sha512-5UbZ/TqWi/DAmHIZL4NvhdpgTwglszRiddkRiQ8cT0IbnE4lutd4XxWUWcLKwsNT1YJv32TtcGWkuthluLiriQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.68", "", { "os": "linux", "cpu": "x64" }, "sha512-9NzVI3GZzmICoIu3YhWBdkEt0KvY27m++tu/MqW+xb6fnvN74jZkRWzlgjTdM70obL4eUGQdvU08sDHgZjsIJw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.67", "", { "os": "win32", "cpu": "arm64" }, "sha512-KNam5rObhN8/U9+GVVuvtAlGXp3MfdMHnw4W2P6YH7xp8HTsLvABUT91SJEyJ/ktVe9e1itLDG2fDHSoA5NbUg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.68", "", { "os": "win32", "cpu": "arm64" }, "sha512-wrAeotyotOplUjQVBSxOGA8GCr9FWXSd6xCEo1PEGo/NjuAOtvHmKoENzyFEP0GzFsjvoUOyy2dZb987oFAn9A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.67", "", { "os": "win32", "cpu": "x64" }, "sha512-740lkOw42zLNh9YfahXjCwV2DS/amH2uMDh3tCADDCLckrMhemIhqArXDiMlalDxDqYspoaZCpBsFVsG9dMS6A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.68", "", { "os": "win32", "cpu": "x64" }, "sha512-w0yBjvzs/oMIwVdWICL4XlUrfsPoVXd4+RDqiuu+Xi/zD0UgANSTRY2asXca+gPe5zPHLsxvz1bAG0Z7uGtmyw=="],
"@opentui/solid": ["@opentui/solid@0.1.67", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.67", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-dVNq0+PJIdNb63D0T7vcbyVF/ZvLCihGvivTU50zDOzd0Sk5prbrIfpG8+DjMErFubXfdZQvdy/PqFdtw0rjtQ=="],
"@opentui/solid": ["@opentui/solid@0.1.68", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.68", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-S1oHvCQaY+gCQu2kiiksPIScP8i0FiDOlAlLjtfwcRlgeSjzT0wRwFkvoh4uVUPuAlyigox7vMCE3j04SYSGKg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1616,6 +1617,8 @@
"@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ=="],
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"@solid-primitives/scroll": ["@solid-primitives/scroll@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ejq/Z7zKo/6eIEFr1bFLzXFxiGBCMLuqCM8QB8urr3YdPzjSETFLzYRWUyRiDWaBQN0F7k0SY6S7ig5nWOP7vg=="],
"@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1767242400,
"narHash": "sha256-knFaYjeg7swqG1dljj1hOxfg39zrIy8pfGuicjm9s+o=",
"lastModified": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c04833a1e584401bb63c1a63ddc51a71e6aa457a",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
"type": "github"
},
"original": {
+16 -4
View File
@@ -66,10 +66,10 @@
mkNodeModules = pkgs.callPackage ./nix/node-modules.nix {
hash = nodeModulesHash;
};
mkPackage = pkgs.callPackage ./nix/opencode.nix { };
in
{
default = mkPackage {
mkOpencode = pkgs.callPackage ./nix/opencode.nix { };
mkDesktop = pkgs.callPackage ./nix/desktop.nix { };
opencodePkg = mkOpencode {
inherit (packageJson) version;
src = ./.;
scripts = ./nix/scripts;
@@ -77,6 +77,18 @@
modelsDev = "${modelsDev.${system}}/dist/_api.json";
inherit mkNodeModules;
};
desktopPkg = mkDesktop {
inherit (packageJson) version;
src = ./.;
scripts = ./nix/scripts;
mkNodeModules = mkNodeModules;
opencode = opencodePkg;
};
in
{
default = opencodePkg;
desktop = desktopPkg;
}
);
+2 -2
View File
@@ -87,7 +87,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
fetch-depth: 1
- name: Run opencode
uses: sst/opencode/github@latest
uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
with:
@@ -98,7 +98,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
## Support
This is an early release. If you encounter issues or have feedback, please create an issue at https://github.com/sst/opencode/issues.
This is an early release. If you encounter issues or have feedback, please create an issue at https://github.com/anomalyco/opencode/issues.
## Development
+1 -1
View File
@@ -41,7 +41,7 @@ runs:
id: version
shell: bash
run: |
VERSION=$(curl -sf https://api.github.com/repos/sst/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
VERSION=$(curl -sf https://api.github.com/repos/anomalyco/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT
- name: Cache opencode
+1
View File
@@ -3,6 +3,7 @@
"module": "index.ts",
"type": "module",
"private": true,
"license": "MIT",
"devDependencies": {
"@types/bun": "catalog:"
},
+1
View File
@@ -104,6 +104,7 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS4"),
new sst.Secret("ZEN_MODELS5"),
new sst.Secret("ZEN_MODELS6"),
new sst.Secret("ZEN_MODELS7"),
]
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
+5 -5
View File
@@ -147,8 +147,8 @@ INSTALL_DIR=$HOME/.opencode/bin
mkdir -p "$INSTALL_DIR"
if [ -z "$requested_version" ]; then
url="https://github.com/sst/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
@@ -157,14 +157,14 @@ if [ -z "$requested_version" ]; then
else
# Strip leading 'v' if present
requested_version="${requested_version#v}"
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version
# Verify the release exists before downloading
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/sst/opencode/releases/tag/v${requested_version}")
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
echo -e "${MUTED}Available releases: https://github.com/sst/opencode/releases${NC}"
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
exit 1
fi
fi
+145
View File
@@ -0,0 +1,145 @@
{
lib,
stdenv,
rustPlatform,
bun,
pkg-config,
dbus ? null,
openssl,
glib ? null,
gtk3 ? null,
libsoup_3 ? null,
webkitgtk_4_1 ? null,
librsvg ? null,
libappindicator-gtk3 ? null,
cargo,
rustc,
makeBinaryWrapper,
nodejs,
jq,
}:
args:
let
scripts = args.scripts;
mkModules =
attrs:
args.mkNodeModules (
attrs
// {
canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
}
);
in
rustPlatform.buildRustPackage rec {
pname = "opencode-desktop";
version = args.version;
src = args.src;
# We need to set the root for cargo, but we also need access to the whole repo.
postUnpack = ''
# Update sourceRoot to point to the tauri app
sourceRoot+=/packages/desktop/src-tauri
'';
cargoLock = {
lockFile = ../packages/desktop/src-tauri/Cargo.lock;
allowBuiltinFetchGit = true;
};
node_modules = mkModules {
version = version;
src = src;
};
nativeBuildInputs = [
pkg-config
bun
makeBinaryWrapper
cargo
rustc
nodejs
jq
];
buildInputs = [
openssl
]
++ lib.optionals stdenv.isLinux [
dbus
glib
gtk3
libsoup_3
webkitgtk_4_1
librsvg
libappindicator-gtk3
];
preBuild = ''
# Restore node_modules
pushd ../../..
# Copy node_modules from the fixed-output derivation
# We use cp -r --no-preserve=mode to ensure we can write to them if needed,
# though we usually just read.
cp -r ${node_modules}/node_modules .
cp -r ${node_modules}/packages .
# Ensure node_modules is writable so patchShebangs can update script headers
chmod -R u+w node_modules
# Ensure workspace packages are writable for tsgo incremental outputs (.tsbuildinfo)
chmod -R u+w packages
# Patch shebangs so scripts can run
patchShebangs node_modules
# Copy sidecar
mkdir -p packages/desktop/src-tauri/sidecars
targetTriple=${stdenv.hostPlatform.rust.rustcTarget}
cp ${args.opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-$targetTriple
# Merge prod config into tauri.conf.json
if ! jq -s '.[0] * .[1]' \
packages/desktop/src-tauri/tauri.conf.json \
packages/desktop/src-tauri/tauri.prod.conf.json \
> packages/desktop/src-tauri/tauri.conf.json.tmp; then
echo "Error: failed to merge tauri.conf.json with tauri.prod.conf.json" >&2
exit 1
fi
mv packages/desktop/src-tauri/tauri.conf.json.tmp packages/desktop/src-tauri/tauri.conf.json
# Build the frontend
cd packages/desktop
# The 'build' script runs 'bun run typecheck && vite build'.
bun run build
popd
'';
# Tauri bundles the assets during the rust build phase (which happens after preBuild).
# It looks for them in the location specified in tauri.conf.json.
postInstall = lib.optionalString stdenv.isLinux ''
# Wrap the binary to ensure it finds the libraries
wrapProgram $out/bin/opencode-desktop \
--prefix LD_LIBRARY_PATH : ${
lib.makeLibraryPath [
gtk3
webkitgtk_4_1
librsvg
glib
libsoup_3
]
}
'';
meta = with lib; {
description = "OpenCode Desktop App";
homepage = "https://opencode.ai";
license = licenses.mit;
maintainers = with maintainers; [ ];
mainProgram = "opencode-desktop";
platforms = platforms.linux ++ platforms.darwin;
};
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-uJDhOieOdMQLORyuOWtgtjLoMnNEQPrDcyij9TX0aTw="
"nodeModules": "sha256-OJ3C4RMzfbbG1Fwa/5yru0rlISj+28UPITMNBEU5AeM="
}
+1 -1
View File
@@ -125,7 +125,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
It combines a TypeScript/JavaScript core with a Go-based TUI
to provide an interactive AI coding experience.
'';
homepage = "https://github.com/sst/opencode";
homepage = "https://github.com/anomalyco/opencode";
license = lib.licenses.mit;
platforms = [
"aarch64-linux"
+1 -1
View File
@@ -76,7 +76,7 @@
},
"repository": {
"type": "git",
"url": "https://github.com/sst/opencode"
"url": "https://github.com/anomalyco/opencode"
},
"license": "MIT",
"prettier": {
+1 -1
View File
@@ -1,6 +1,6 @@
## Debugging
- To test the opencode app, use the playwrite mcp server, the app is already
- To test the opencode app, use the playwright MCP server, the app is already
running at http://localhost:3000
- NEVER try to restart the app, or the server process, EVER.
+1 -1
View File
@@ -47,7 +47,7 @@
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-screen"></div>
<div id="root" class="flex flex-col h-dvh"></div>
<script src="/src/entry.tsx" type="module"></script>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.0.224",
"version": "1.1.2",
"description": "",
"type": "module",
"exports": {
+297 -377
View File
@@ -12,6 +12,7 @@ import {
usePrompt,
ImageAttachmentPart,
AgentPart,
FileAttachmentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
@@ -33,6 +34,12 @@ import { persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id"
import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "@opencode-ai/util/binary"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -40,6 +47,8 @@ const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
interface PromptInputProps {
class?: string
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
}
const PLACEHOLDERS = [
@@ -83,6 +92,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const platform = usePlatform()
const local = useLocal()
const files = useFile()
const prompt = usePrompt()
@@ -95,6 +106,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
const scrollCursorIntoView = () => {
const container = scrollRef
@@ -151,7 +163,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
imageAttachments: ImageAttachmentPart[]
mode: "normal" | "shell"
applyingHistory: boolean
killBuffer: string
}>({
popover: null,
historyIndex: -1,
@@ -161,7 +172,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
imageAttachments: [],
mode: "normal",
applyingHistory: false,
killBuffer: "",
})
const MAX_HISTORY = 100
@@ -430,6 +440,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
active: slashActive,
onInput: slashOnInput,
onKeyDown: slashOnKeyDown,
refetch: slashRefetch,
} = useFilteredList<SlashCommand>({
items: slashCommands,
key: (x) => x?.id,
@@ -437,32 +448,78 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect: handleSlashSelect,
})
const createPill = (part: FileAttachmentPart | AgentPart) => {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", part.type)
if (part.type === "file") pill.setAttribute("data-path", part.path)
if (part.type === "agent") pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
return pill
}
const isNormalizedEditor = () =>
Array.from(editorRef.childNodes).every((node) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ""
if (!text.includes("\u200B")) return true
if (text !== "\u200B") return false
const prev = node.previousSibling
const next = node.nextSibling
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
if (!prevIsBr && !nextIsBr) return false
if (nextIsBr && !prevIsBr && prev) return false
return true
}
if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement
if (el.dataset.type === "file") return true
if (el.dataset.type === "agent") return true
return el.tagName === "BR"
})
const renderEditor = (parts: Prompt) => {
editorRef.innerHTML = ""
for (const part of parts) {
if (part.type === "text") {
editorRef.appendChild(createTextFragment(part.content))
continue
}
if (part.type === "file" || part.type === "agent") {
editorRef.appendChild(createPill(part))
}
}
}
createEffect(
on(
() => sync.data.command,
() => slashRefetch(),
{ defer: true },
),
)
// Auto-scroll active command into view when navigating with keyboard
createEffect(() => {
const activeId = slashActive()
if (!activeId || !slashPopoverRef) return
requestAnimationFrame(() => {
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
})
})
createEffect(
on(
() => prompt.current(),
(currentParts) => {
const domParts = parseFromDOM()
const normalized = Array.from(editorRef.childNodes).every((node) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ""
if (!text.includes("\u200B")) return true
if (text !== "\u200B") return false
const prev = node.previousSibling
const next = node.nextSibling
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
if (!prevIsBr && !nextIsBr) return false
if (nextIsBr && !prevIsBr && prev) return false
return true
}
if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement
if (el.dataset.type === "file") return true
if (el.dataset.type === "agent") return true
return el.tagName === "BR"
})
if (normalized && isPromptEqual(currentParts, domParts)) return
if (isNormalizedEditor() && isPromptEqual(currentParts, domParts)) return
const selection = window.getSelection()
let cursorPosition: number | null = null
@@ -470,30 +527,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
cursorPosition = getCursorPosition(editorRef)
}
editorRef.innerHTML = ""
currentParts.forEach((part) => {
if (part.type === "text") {
editorRef.appendChild(createTextFragment(part.content))
} else if (part.type === "file") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "file")
pill.setAttribute("data-path", part.path)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
editorRef.appendChild(pill)
} else if (part.type === "agent") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "agent")
pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
editorRef.appendChild(pill)
}
})
renderEditor(currentParts)
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
@@ -671,40 +705,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
if (part.type === "file") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "file")
pill.setAttribute("data-path", part.path)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length
setRangeEdge(range, "start", start)
setRangeEdge(range, "end", cursorPosition)
}
range.deleteContents()
range.insertNode(gap)
range.insertNode(pill)
range.setStartAfter(gap)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
} else if (part.type === "agent") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "agent")
pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
if (part.type === "file" || part.type === "agent") {
const pill = createPill(part)
const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
@@ -750,77 +752,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("popover", null)
}
const setSelectionOffsets = (start: number, end: number) => {
const selection = window.getSelection()
if (!selection) return false
const length = promptLength(prompt.current())
const a = Math.max(0, Math.min(start, length))
const b = Math.max(0, Math.min(end, length))
const rangeStart = Math.min(a, b)
const rangeEnd = Math.max(a, b)
const range = document.createRange()
range.selectNodeContents(editorRef)
const setEdge = (edge: "start" | "end", offset: number) => {
let remaining = offset
const nodes = Array.from(editorRef.childNodes)
for (const node of nodes) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining)
if (edge === "end") range.setEnd(node, remaining)
return
}
if ((isFile || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
if (edge === "end" && remaining > 0) range.setEndAfter(node)
return
}
remaining -= length
}
const last = editorRef.lastChild
if (!last) {
if (edge === "start") range.setStart(editorRef, 0)
if (edge === "end") range.setEnd(editorRef, 0)
return
}
if (edge === "start") range.setStartAfter(last)
if (edge === "end") range.setEndAfter(last)
}
setEdge("start", rangeStart)
setEdge("end", rangeEnd)
selection.removeAllRanges()
selection.addRange(range)
return true
}
const replaceOffsets = (start: number, end: number, content: string) => {
if (!setSelectionOffsets(start, end)) return false
addPart({ type: "text", content, start: 0, end: 0 })
return true
}
const killText = (start: number, end: number) => {
if (start === end) return
const current = prompt.current()
if (!current.every((part) => part.type === "text")) return
const text = current.map((part) => part.content).join("")
setStore("killBuffer", text.slice(start, end))
}
const abort = () =>
sdk.client.session
.abort({
@@ -942,7 +873,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
const alt = event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey
if (ctrl && event.code === "KeyG") {
if (store.popover) {
@@ -957,148 +887,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (ctrl || alt) {
const { collapsed, cursorPosition, textLength } = getCaretState()
if (collapsed) {
const current = prompt.current()
const text = current.map((part) => ("content" in part ? part.content : "")).join("")
if (ctrl) {
if (event.code === "KeyA") {
const pos = text.lastIndexOf("\n", cursorPosition - 1) + 1
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyE") {
const next = text.indexOf("\n", cursorPosition)
const pos = next === -1 ? textLength : next
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyB") {
const pos = Math.max(0, cursorPosition - 1)
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyF") {
const pos = Math.min(textLength, cursorPosition + 1)
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyD") {
if (store.mode === "shell" && cursorPosition === 0 && textLength === 0) {
setStore("mode", "normal")
event.preventDefault()
return
}
if (cursorPosition >= textLength) return
replaceOffsets(cursorPosition, cursorPosition + 1, "")
event.preventDefault()
return
}
if (event.code === "KeyK") {
const next = text.indexOf("\n", cursorPosition)
const lineEnd = next === -1 ? textLength : next
const end = lineEnd === cursorPosition && lineEnd < textLength ? lineEnd + 1 : lineEnd
if (end === cursorPosition) return
killText(cursorPosition, end)
replaceOffsets(cursorPosition, end, "")
event.preventDefault()
return
}
if (event.code === "KeyU") {
const start = text.lastIndexOf("\n", cursorPosition - 1) + 1
if (start === cursorPosition) return
killText(start, cursorPosition)
replaceOffsets(start, cursorPosition, "")
event.preventDefault()
return
}
if (event.code === "KeyW") {
let start = cursorPosition
while (start > 0 && /\s/.test(text[start - 1])) start -= 1
while (start > 0 && !/\s/.test(text[start - 1])) start -= 1
if (start === cursorPosition) return
killText(start, cursorPosition)
replaceOffsets(start, cursorPosition, "")
event.preventDefault()
return
}
if (event.code === "KeyY") {
if (!store.killBuffer) return
addPart({ type: "text", content: store.killBuffer, start: 0, end: 0 })
event.preventDefault()
return
}
if (event.code === "KeyT") {
if (!current.every((part) => part.type === "text")) return
if (textLength < 2) return
if (cursorPosition === 0) return
const atEnd = cursorPosition === textLength
const first = atEnd ? cursorPosition - 2 : cursorPosition - 1
const second = atEnd ? cursorPosition - 1 : cursorPosition
if (text[first] === "\n" || text[second] === "\n") return
replaceOffsets(first, second + 1, `${text[second]}${text[first]}`)
event.preventDefault()
return
}
}
if (alt) {
if (event.code === "KeyB") {
let pos = cursorPosition
while (pos > 0 && /\s/.test(text[pos - 1])) pos -= 1
while (pos > 0 && !/\s/.test(text[pos - 1])) pos -= 1
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyF") {
let pos = cursorPosition
while (pos < textLength && /\s/.test(text[pos])) pos += 1
while (pos < textLength && !/\s/.test(text[pos])) pos += 1
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyD") {
let end = cursorPosition
while (end < textLength && /\s/.test(text[end])) end += 1
while (end < textLength && !/\s/.test(text[end])) end += 1
if (end === cursorPosition) return
killText(cursorPosition, end)
replaceOffsets(cursorPosition, end, "")
event.preventDefault()
return
}
}
}
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
if (event.altKey || event.ctrlKey || event.metaKey) return
const { collapsed } = getCaretState()
@@ -1152,30 +940,169 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSubmit = async (event: Event) => {
event.preventDefault()
const currentPrompt = prompt.current()
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
const hasImageAttachments = store.imageAttachments.length > 0
if (text.trim().length === 0 && !hasImageAttachments) {
const images = store.imageAttachments.slice()
const mode = store.mode
if (text.trim().length === 0 && images.length === 0) {
if (working()) abort()
return
}
addToHistory(currentPrompt, store.mode)
const currentModel = local.model.current()
const currentAgent = local.agent.current()
if (!currentModel || !currentAgent) {
showToast({
title: "Select an agent and model",
description: "Choose an agent and model before sending a prompt.",
})
return
}
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return "Request failed"
}
addToHistory(currentPrompt, mode)
setStore("historyIndex", -1)
setStore("savedPrompt", null)
let existing = info()
if (!existing) {
const created = await sdk.client.session.create()
existing = created.data ?? undefined
if (existing) navigate(existing.id)
}
if (!existing) return
const projectDirectory = sdk.directory
const isNewSession = !params.id
const worktreeSelection = props.newSessionWorktree ?? "main"
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const fileAttachments = currentPrompt.filter(
(part) => part.type === "file",
) as import("@/context/prompt").FileAttachmentPart[]
let sessionDirectory = projectDirectory
let client = sdk.client
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await client.worktree
.create({ directory: projectDirectory })
.then((x) => x.data)
.catch((err) => {
showToast({
title: "Failed to create worktree",
description: errorMessage(err),
})
return undefined
})
if (!createdWorktree?.directory) {
showToast({
title: "Failed to create worktree",
description: "Request failed",
})
return
}
sessionDirectory = createdWorktree.directory
}
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
if (sessionDirectory !== projectDirectory) {
client = createOpencodeClient({
baseUrl: sdk.url,
fetch: platform.fetch,
directory: sessionDirectory,
throwOnError: true,
})
globalSync.child(sessionDirectory)
}
props.onNewSessionWorktreeReset?.()
}
let session = info()
if (!session && isNewSession) {
session = await client.session.create().then((x) => x.data ?? undefined)
if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
if (!session) return
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const variant = local.model.variant.current()
const clearInput = () => {
prompt.reset()
setStore("imageAttachments", [])
setStore("mode", "normal")
setStore("popover", null)
}
const restoreInput = () => {
prompt.set(currentPrompt, promptLength(currentPrompt))
setStore("imageAttachments", images)
setStore("mode", mode)
setStore("popover", null)
requestAnimationFrame(() => {
editorRef.focus()
setCursorPosition(editorRef, promptLength(currentPrompt))
queueScroll()
})
}
if (mode === "shell") {
clearInput()
client.session
.shell({
sessionID: session.id,
agent,
model,
command: text,
})
.catch((err) => {
showToast({
title: "Failed to send shell command",
description: errorMessage(err),
})
restoreInput()
})
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
client.session
.command({
sessionID: session.id,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
})
.catch((err) => {
showToast({
title: "Failed to send command",
description: errorMessage(err),
})
restoreInput()
})
return
}
}
const toAbsolutePath = (path: string) =>
path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
const fileAttachmentParts = fileAttachments.map((attachment) => {
@@ -1247,7 +1174,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
addContextFile(item.path, item.selection)
}
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
const imageAttachmentParts = images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
@@ -1255,60 +1182,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
filename: attachment.filename,
}))
const isShellMode = store.mode === "shell"
editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
setStore("imageAttachments", [])
setStore("mode", "normal")
const currentModel = local.model.current()
const currentAgent = local.agent.current()
if (!currentModel || !currentAgent) {
console.warn("No agent or model available for prompt submission")
return
}
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const variant = local.model.variant.current()
if (isShellMode) {
sdk.client.session
.shell({
sessionID: existing.id,
agent,
model,
command: text,
})
.catch((e) => {
console.error("Failed to send shell command", e)
})
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName)
if (customCommand) {
sdk.client.session
.command({
sessionID: existing.id,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
})
.catch((e) => {
console.error("Failed to send command", e)
})
return
}
}
const messageID = Identifier.ascending("message")
const textPart = {
id: Identifier.ascending("part"),
@@ -1322,31 +1195,74 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
...agentAttachmentParts,
...imageAttachmentParts,
]
const optimisticParts = requestParts.map((part) => ({
...part,
sessionID: existing.id,
sessionID: session.id,
messageID,
}))
})) as unknown as Part[]
sync.session.addOptimisticMessage({
sessionID: existing.id,
messageID,
parts: optimisticParts,
const optimisticMessage: Message = {
id: messageID,
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent,
model,
})
}
sdk.client.session
const setSyncStore = sessionDirectory === projectDirectory ? sync.set : globalSync.child(sessionDirectory)[1]
const addOptimisticMessage = () => {
setSyncStore(
produce((draft) => {
const messages = draft.message[session.id]
if (!messages) {
draft.message[session.id] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
}
draft.part[messageID] = optimisticParts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
}),
)
}
const removeOptimisticMessage = () => {
setSyncStore(
produce((draft) => {
const messages = draft.message[session.id]
if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[messageID]
}),
)
}
clearInput()
addOptimisticMessage()
client.session
.prompt({
sessionID: existing.id,
sessionID: session.id,
agent,
model,
messageID,
parts: requestParts,
variant,
})
.catch((e) => {
console.error("Failed to send prompt", e)
.catch((err) => {
showToast({
title: "Failed to send prompt",
description: errorMessage(err),
})
removeOptimisticMessage()
restoreInput()
})
}
@@ -1354,6 +1270,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<Show when={store.popover}>
<div
ref={(el) => {
if (store.popover === "slash") slashPopoverRef = el
}}
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
@@ -1412,6 +1331,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<For each={slashFlat()}>
{(cmd) => (
<button
data-slash-id={cmd.id}
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,
@@ -1665,7 +1585,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES.join(",")}
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0]
@@ -1676,7 +1596,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex items-center gap-2">
<SessionContextUsage />
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value="Attach image">
<Tooltip placement="top" value="Attach file">
<Button type="button" variant="ghost" class="size-6" onClick={() => fileInputRef.click()}>
<Icon name="photo" class="size-4.5" />
</Button>
@@ -305,13 +305,19 @@ export function SessionContextTab(props: SessionContextTabProps) {
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const restoreScroll = () => {
const restoreScroll = (retries = 0) => {
const el = scroll
if (!el) return
const s = props.view()?.scroll("context")
if (!s) return
// Wait for content to be scrollable - content may not have rendered yet
if (el.scrollHeight <= el.clientHeight && retries < 10) {
requestAnimationFrame(() => restoreScroll(retries + 1))
return
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
@@ -1,4 +1,4 @@
import { createMemo, createResource, Show } from "solid-js"
import { createEffect, createMemo, createResource, Show } from "solid-js"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { getFilename } from "@opencode-ai/util/path"
import { base64Encode } from "@opencode-ai/util/encode"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -31,10 +31,11 @@ export function SessionHeader() {
const dialog = useDialog()
const sync = useSync()
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const branch = createMemo(() => sync.data.vcs?.branch)
function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
@@ -46,7 +47,7 @@ export function SessionHeader() {
}
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex">
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
@@ -60,12 +61,8 @@ export function SessionHeader() {
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
current={sync.directory}
label={(x) => {
const name = getFilename(x)
const b = x === sync.directory ? branch() : undefined
return b ? `${name}:${b}` : name
}}
current={sync.project?.worktree ?? projectDirectory()}
label={(x) => getFilename(x)}
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
@@ -191,7 +188,7 @@ export function SessionHeader() {
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: sync.directory })
.share({ sessionID: session.id, directory: projectDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
@@ -1,12 +1,41 @@
import { Show } from "solid-js"
import { Show, createMemo } from "solid-js"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Select } from "@opencode-ai/ui/select"
export function NewSessionView() {
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
interface NewSessionViewProps {
worktree: string
onWorktreeChange: (value: string) => void
}
export function NewSessionView(props: NewSessionViewProps) {
const sync = useSync()
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
const current = createMemo(() => {
const selection = props.worktree
if (options().includes(selection)) return selection
return MAIN_WORKTREE
})
const label = (value: string) => {
if (value === MAIN_WORKTREE) {
const branch = sync.data.vcs?.branch
if (branch) return `Current branch (${branch})`
return "Main branch"
}
if (value === CREATE_WORKTREE) return "Create new worktree"
return getFilename(value)
}
return (
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
<div class="text-20-medium text-text-weaker">New session</div>
@@ -17,6 +46,21 @@ export function NewSessionView() {
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
</div>
</div>
<div class="flex justify-center items-center gap-1">
<Icon name="branch" size="small" />
<Select
options={options()}
current={current()}
value={(x) => x}
label={label}
onSelect={(value) => {
props.onWorktreeChange(value ?? MAIN_WORKTREE)
}}
size="normal"
variant="ghost"
class="text-12-medium"
/>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-center gap-3">
-1
View File
@@ -31,7 +31,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform()
const sdk = createOpencodeClient({
baseUrl: server.url,
signal: AbortSignal.timeout(1000 * 60 * 10),
fetch: platform.fetch,
throwOnError: true,
})
+5 -1
View File
@@ -90,7 +90,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
function enrich(project: { worktree: string; expanded: boolean }) {
const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
const [childStore] = globalSync.child(project.worktree)
const projectID = childStore.project
const metadata = projectID
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
return [
{
...project,
+13 -6
View File
@@ -160,6 +160,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
),
)
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
const userVisibilityMap = createMemo(() => {
const map = new Map<string, "show" | "hide">()
for (const item of store.user) {
map.set(`${item.providerID}:${item.modelID}`, item.visibility)
}
return map
})
const list = createMemo(() =>
available().map((m) => ({
...m,
@@ -264,12 +274,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
},
visible(model: ModelKey) {
const user = store.user.find((x) => x.modelID === model.modelID && x.providerID === model.providerID)
return (
user?.visibility !== "hide" &&
(latest().find((x) => x.modelID === model.modelID && x.providerID === model.providerID) ||
user?.visibility === "show")
)
const key = `${model.providerID}:${model.modelID}`
const visibility = userVisibilityMap().get(key)
return visibility !== "hide" && (latestSet().has(key) || visibility === "show")
},
setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide")
+1 -1
View File
@@ -3,7 +3,7 @@ import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
export type Platform = {
/** Platform discriminator */
platform: "web" | "tauri"
platform: "web" | "desktop"
/** App version */
version?: string
-1
View File
@@ -11,7 +11,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const globalSDK = useGlobalSDK()
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
signal: AbortSignal.timeout(1000 * 60 * 10),
fetch: platform.fetch,
directory: props.directory,
throwOnError: true,
+2 -3
View File
@@ -11,8 +11,7 @@ export function normalizeServerUrl(input: string) {
const trimmed = input.trim()
if (!trimmed) return
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
const cleaned = withProtocol.replace(/\/+$/, "")
return cleaned.replace(/^(https?:\/\/[^/]+).*/, "$1")
return withProtocol.replace(/\/+$/, "")
}
export function serverDisplayName(url: string) {
@@ -100,7 +99,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal: AbortSignal.timeout(2000),
signal: AbortSignal.timeout(3000),
})
return sdk.global
.health()
+2 -2
View File
@@ -53,8 +53,8 @@ export default function Home() {
}
return (
<div class="mx-auto mt-55">
<Logo class="w-xl opacity-12" />
<div class="mx-auto mt-55 w-full md:w-auto px-4">
<Logo class="md:w-xl opacity-12" />
<Button
size="large"
variant="ghost"
+105 -76
View File
@@ -172,9 +172,9 @@ export default function Layout(props: ParentProps) {
const perm = e.details.properties
if (permission.autoResponds(perm)) return
const sessionKey = `${directory}:${perm.sessionID}`
const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === perm.sessionID)
const sessionKey = `${directory}:${perm.sessionID}`
const sessionTitle = session?.title ?? "New session"
const projectName = getFilename(directory)
@@ -665,14 +665,13 @@ export default function Layout(props: ParentProps) {
<>
<div
data-session-id={props.session.id}
class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
class="group/session relative w-full 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": "16px" }}
>
<Tooltip placement={props.mobile ? "bottom" : "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"
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
>
<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
@@ -740,10 +739,17 @@ export default function Layout(props: ParentProps) {
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.project.worktree)
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
const slug = createMemo(() => base64Encode(props.project.worktree))
const defaultWorktree = createMemo(() => base64Encode(props.project.worktree))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const [store, setProjectStore] = globalSync.child(props.project.worktree)
const sessions = createMemo(() => store.session.toSorted(sortSessions))
const stores = createMemo(() =>
[props.project.worktree, ...(props.project.sandboxes ?? [])].map((dir) => globalSync.child(dir)[0]),
)
const sessions = createMemo(() =>
stores()
.flatMap((store) => store.session.filter((session) => session.directory === store.path.directory))
.toSorted(sortSessions),
)
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
const loadMoreSessions = async () => {
@@ -799,7 +805,7 @@ export default function Layout(props: ParentProps) {
</DropdownMenu.Portal>
</DropdownMenu>
<TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
<IconButton as={A} href={`${defaultWorktree()}/session`} icon="plus-small" variant="ghost" />
</TooltipKeybind>
</div>
</Button>
@@ -807,7 +813,12 @@ export default function Layout(props: ParentProps) {
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
<For each={rootSessions()}>
{(session) => (
<SessionItem session={session} slug={slug()} project={props.project} mobile={props.mobile} />
<SessionItem
session={session}
slug={base64Encode(session.directory)}
project={props.project}
mobile={props.mobile}
/>
)}
</For>
<Show when={rootSessions().length === 0}>
@@ -819,7 +830,7 @@ export default function Layout(props: ParentProps) {
<div class="flex-1 min-w-0">
<Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
<A
href={`${slug()}/session`}
href={`${defaultWorktree()}/session`}
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
>
<div class="flex items-center self-stretch gap-6 justify-between">
@@ -875,76 +886,85 @@ export default function Layout(props: ParentProps) {
const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
return (
<>
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
<div class="flex flex-col self-stretch h-full items-center justify-between overflow-hidden min-h-0">
<div class="flex flex-col items-start self-stretch gap-4 min-h-0">
<Show when={!sidebarProps.mobile}>
<A href="/" class="shrink-0 h-8 flex items-center justify-start px-2" data-tauri-drag-region>
<Mark class="shrink-0" />
</A>
</Show>
<Show when={!sidebarProps.mobile}>
<TooltipKeybind
class="shrink-0"
placement="right"
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
inactive={expanded()}
>
<Button
variant="ghost"
size="large"
class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
onClick={layout.sidebar.toggle}
>
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
size="small"
class="group-hover/sidebar-toggle:hidden"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
size="small"
class="hidden group-hover/sidebar-toggle:inline-block"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
size="small"
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
<Show when={layout.sidebar.opened()}>
<div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
Toggle sidebar
</div>
</Show>
</Button>
</TooltipKeybind>
</Show>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!sidebarProps.mobile) scrollContainerRef = el
classList={{
"border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0": true,
"justify-start": expanded(),
}}
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} mobile={sidebarProps.mobile} />}
</For>
</SortableProvider>
<A href="/" class="shrink-0 h-8 flex items-center justify-start px-2 w-full" data-tauri-drag-region>
<Mark class="shrink-0" />
</A>
</div>
<DragOverlay>
<ProjectDragOverlay />
</DragOverlay>
</DragDropProvider>
</Show>
<div class="flex flex-col items-start self-stretch gap-4 px-2 overflow-hidden min-h-0">
<Show when={!sidebarProps.mobile}>
<TooltipKeybind
class="shrink-0"
placement="right"
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
inactive={expanded()}
>
<Button
variant="ghost"
size="large"
class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
onClick={layout.sidebar.toggle}
>
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
size="small"
class="group-hover/sidebar-toggle:hidden"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
size="small"
class="hidden group-hover/sidebar-toggle:inline-block"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
size="small"
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
<Show when={layout.sidebar.opened()}>
<div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
Toggle sidebar
</div>
</Show>
</Button>
</TooltipKeybind>
</Show>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!sidebarProps.mobile) scrollContainerRef = el
}}
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} mobile={sidebarProps.mobile} />}
</For>
</SortableProvider>
</div>
<DragOverlay>
<ProjectDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
</div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Switch>
@@ -1017,7 +1037,7 @@ export default function Layout(props: ParentProps) {
</Button>
</Tooltip>
</div>
</>
</div>
)
}
@@ -1065,12 +1085,21 @@ export default function Layout(props: ParentProps) {
/>
<div
classList={{
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pb-5 transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
<div class="border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0">
<A
href="/"
class="shrink-0 h-8 flex items-center justify-start px-2 w-full"
onClick={() => layout.mobileSidebar.hide()}
>
<Mark class="shrink-0" />
</A>
</div>
<SidebarContent mobile />
</div>
</div>
+204 -244
View File
@@ -1,4 +1,4 @@
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, createRenderEffect, batch } from "solid-js"
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, batch } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
@@ -24,7 +24,7 @@ import { useSync } from "@/context/sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { Terminal } from "@/components/terminal"
import { checksum } from "@opencode-ai/util/encode"
import { checksum, base64Decode } 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"
@@ -47,6 +47,7 @@ import {
SortableTerminalTab,
NewSessionView,
} from "@/components/session"
import { usePlatform } from "@/context/platform"
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
@@ -73,13 +74,19 @@ function SessionReviewTab(props: SessionReviewTabProps) {
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const restoreScroll = () => {
const restoreScroll = (retries = 0) => {
const el = scroll
if (!el) return
const s = props.view().scroll("review")
if (!s) return
// Wait for content to be scrollable - content may not have rendered yet
if (el.scrollHeight <= el.clientHeight && retries < 10) {
requestAnimationFrame(() => restoreScroll(retries + 1))
return
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
@@ -147,6 +154,7 @@ export default function Page() {
const dialog = useDialog()
const codeComponent = useCodeComponent()
const command = useCommand()
const platform = usePlatform()
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
@@ -218,20 +226,12 @@ export default function Page() {
return sync.data.message[id] !== undefined
})
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{ equals: same },
)
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
const visibleUserMessages = createMemo(() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
}, emptyUserMessages)
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
createEffect(
@@ -249,13 +249,10 @@ export default function Page() {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
userInteracted: false,
stepsExpanded: true,
mobileStepsExpanded: {} as Record<string, boolean>,
expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "review",
ignoreScrollSpy: false,
initialScrollDone: !params.id,
newSessionWorktree: "main",
})
const activeMessage = createMemo(() => {
@@ -316,47 +313,24 @@ export default function Page() {
),
)
createEffect(
on(
() => params.id,
(id) => {
const status = sync.data.session_status[id ?? ""] ?? idle
batch(() => {
setStore("userInteracted", false)
setStore("stepsExpanded", status.type !== "idle")
})
},
),
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
createEffect(
on(
() => status().type,
(type) => {
if (type !== "idle") return
batch(() => {
setStore("userInteracted", false)
setStore("stepsExpanded", false)
})
() => params.id,
() => {
setStore("messageId", undefined)
setStore("expanded", {})
},
{ defer: true },
),
)
const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
createRenderEffect((prev) => {
const isWorking = working()
if (!prev && isWorking) {
setStore("stepsExpanded", true)
}
if (prev && !isWorking && !store.userInteracted) {
setStore("stepsExpanded", false)
}
return isWorking
}, working())
createEffect(() => {
const id = lastUserMessage()?.id
if (!id) return
setStore("expanded", id, status().type !== "idle")
})
command.register(() => [
{
@@ -405,12 +379,16 @@ export default function Page() {
{
id: "steps.toggle",
title: "Toggle steps",
description: "Show or hide the steps",
description: "Show or hide steps for the current message",
category: "View",
keybind: "mod+e",
slash: "steps",
disabled: !params.id,
onSelect: () => setStore("stepsExpanded", (x) => !x),
onSelect: () => {
const msg = activeMessage()
if (!msg) return
setStore("expanded", msg.id, (open: boolean | undefined) => !open)
},
},
{
id: "message.previous",
@@ -555,6 +533,31 @@ export default function Page() {
setActiveMessage(priorMsg)
},
},
{
id: "session.compact",
title: "Compact session",
description: "Summarize the session to reduce context size",
category: "Session",
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: "No model selected",
description: "Connect a provider to summarize this session",
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
},
},
])
const handleKeyDown = (event: KeyboardEvent) => {
@@ -655,204 +658,76 @@ export default function Page() {
const isWorking = createMemo(() => status().type !== "idle")
const autoScroll = createAutoScroll({
working: isWorking,
onUserInteracted: () => setStore("userInteracted", true),
})
let scrollContainer: HTMLDivElement | undefined
let initialScrollFrame: number | undefined
let initialScrollTarget: string | undefined
const cancelInitialScroll = () => {
if (initialScrollFrame === undefined) return
cancelAnimationFrame(initialScrollFrame)
initialScrollFrame = undefined
}
const ensureInitialScroll = () => {
cancelInitialScroll()
initialScrollFrame = requestAnimationFrame(() => {
initialScrollFrame = undefined
if (!params.id) {
initialScrollTarget = undefined
setStore("initialScrollDone", true)
return
}
const msgs = visibleUserMessages()
if (msgs.length === 0) {
if (!messagesReady()) {
ensureInitialScroll()
return
}
initialScrollTarget = undefined
setStore("initialScrollDone", true)
return
}
const last = msgs[msgs.length - 1]
const el = messageRefs.get(last.id)
if (!el || !scrollContainer) {
ensureInitialScroll()
return
}
scrollToMessage(last, "auto")
initialScrollTarget = last.id
setStore("initialScrollDone", true)
})
}
const setScrollRef = (el: HTMLDivElement | undefined) => {
scrollContainer = el
autoScroll.scrollRef(el)
}
const messageRefs = new Map<string, HTMLDivElement>()
let scrollTimer: number | undefined
createEffect(() => {
const msgs = visibleUserMessages()
if (msgs.length === 0) {
messageRefs.clear()
return
}
const ids = new Set(msgs.map((m) => m.id))
for (const id of messageRefs.keys()) {
if (ids.has(id)) continue
messageRefs.delete(id)
}
})
let scrollSpyIndex = 0
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setStore("ignoreScrollSpy", true)
setActiveMessage(message)
const msgs = visibleUserMessages()
const idx = msgs.findIndex((m) => m.id === message.id)
if (idx >= 0) scrollSpyIndex = idx
const el = messageRefs.get(message.id)
if (el) {
el.scrollIntoView({ behavior, block: "start" })
}
if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
scrollTimer = window.setTimeout(() => setStore("ignoreScrollSpy", false), 1000)
}
let scrollSpyFrame: number | undefined
let scrollSpyTarget: HTMLDivElement | undefined
const anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => {
autoScroll.scrollRef(el)
}
const updateHash = (id: string) => {
window.history.replaceState(null, "", `#${anchor(id)}`)
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setActiveMessage(message)
const el = document.getElementById(anchor(message.id))
if (el) el.scrollIntoView({ behavior, block: "start" })
updateHash(message.id)
}
const getActiveMessageId = (container: HTMLDivElement) => {
const cutoff = container.scrollTop + 100
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
let id: string | undefined
for (const node of nodes) {
const next = node.dataset.messageId
if (!next) continue
if (node.offsetTop > cutoff) break
id = next
}
return id
}
const scheduleScrollSpy = (container: HTMLDivElement) => {
if (store.ignoreScrollSpy) return
scrollSpyTarget = container
if (scrollSpyFrame !== undefined) return
scrollSpyFrame = requestAnimationFrame(() => {
scrollSpyFrame = undefined
const target = scrollSpyTarget
scrollSpyTarget = undefined
if (!target) return
if (store.ignoreScrollSpy) return
const msgs = visibleUserMessages()
const scrollTop = target.scrollTop
const threshold = 100
const cutoff = scrollTop + threshold
const id = getActiveMessageId(target)
if (!id) return
if (id === store.messageId) return
if (msgs.length === 0) return
if (scrollSpyIndex >= msgs.length) scrollSpyIndex = msgs.length - 1
if (scrollSpyIndex < 0) scrollSpyIndex = 0
while (scrollSpyIndex + 1 < msgs.length) {
const next = msgs[scrollSpyIndex + 1]
if (!next) break
const el = messageRefs.get(next.id)
if (!el) break
if (el.offsetTop <= cutoff) {
scrollSpyIndex += 1
continue
}
break
}
while (scrollSpyIndex > 0) {
const cur = msgs[scrollSpyIndex]
if (!cur) break
const el = messageRefs.get(cur.id)
if (!el) break
if (el.offsetTop > cutoff) {
scrollSpyIndex -= 1
continue
}
break
}
const msg = msgs[scrollSpyIndex]
if (!msg) return
if (msg.id === activeMessage()?.id) return
setActiveMessage(msg)
setStore("messageId", id)
})
}
createEffect(
on(
() => params.id,
(id) => {
cancelInitialScroll()
if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
scrollTimer = undefined
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
scrollSpyFrame = undefined
scrollSpyTarget = undefined
messageRefs.clear()
scrollSpyIndex = 0
initialScrollTarget = undefined
setStore("initialScrollDone", !id)
},
{ defer: true },
),
)
createEffect(() => {
const msgs = visibleUserMessages()
const target = msgs.at(-1)?.id
const sessionID = params.id
const ready = messagesReady()
if (!sessionID || !ready) return
if (!params.id) {
setStore("initialScrollDone", true)
initialScrollTarget = undefined
return
}
if (!ready) {
setStore("initialScrollDone", false)
ensureInitialScroll()
return
}
if (!store.initialScrollDone) {
ensureInitialScroll()
return
}
if (!initialScrollTarget && target) {
setStore("initialScrollDone", false)
ensureInitialScroll()
}
})
createEffect(() => {
const msgs = visibleUserMessages()
if (msgs.length === 0) return
requestAnimationFrame(() => {
if (!scrollContainer) return
if (!isDesktop()) return
// Manually trigger spy once to set initial active message based on scroll position
scheduleScrollSpy(scrollContainer)
const id = window.location.hash.slice(1)
const hashTarget = id ? document.getElementById(id) : undefined
if (hashTarget) {
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
return
}
autoScroll.forceScrollToBottom()
})
})
@@ -862,8 +737,6 @@ export default function Page() {
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
cancelInitialScroll()
if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
})
@@ -944,13 +817,10 @@ export default function Page() {
}}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
classList={{
"opacity-0 pointer-events-none": !store.initialScrollDone,
}}
>
<div
ref={autoScroll.contentRef}
class="flex flex-col gap-45 items-start justify-start pb-32 md:pb-40 transition-[margin]"
class="flex flex-col gap-32 items-start justify-start pb-32 md:pb-40 transition-[margin]"
classList={{
"mt-0.5": !showTabs(),
"mt-0": showTabs(),
@@ -959,16 +829,24 @@ export default function Page() {
<For each={visibleUserMessages()}>
{(message) => (
<div
ref={(el) => messageRefs.set(message.id, el)}
class="min-w-0 w-full max-w-full last:min-h-[80vh]"
id={anchor(message.id)}
data-message-id={message.id}
classList={{
"min-w-0 w-full max-w-full": true,
"last:min-h-[calc(100vh-13.5rem)] md:last:min-h-[calc(100vh-14.5rem)]":
platform.platform !== "desktop",
"last:min-h-[calc(100vh-15rem)] md:last:min-h-[calc(100vh-16rem)]":
platform.platform === "desktop",
}}
>
<SessionTurn
sessionID={params.id!}
messageID={message.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)}
stepsExpanded={store.expanded[message.id] ?? false}
onStepsExpandedToggle={() =>
setStore("expanded", message.id, (open: boolean | undefined) => !open)
}
classes={{
root: "min-w-0 w-full relative",
content:
@@ -992,7 +870,10 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<NewSessionView />
<NewSessionView
worktree={store.newSessionWorktree}
onWorktreeChange={(value) => setStore("newSessionWorktree", value)}
/>
</Match>
</Switch>
</div>
@@ -1009,6 +890,8 @@ export default function Page() {
ref={(el) => {
inputRef = el
}}
newSessionWorktree={store.newSessionWorktree}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
/>
</div>
</div>
@@ -1128,6 +1011,35 @@ export default function Page() {
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => checksum(contents()))
const isImage = createMemo(() => {
const c = state()?.content
return (
c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
)
})
const isSvg = createMemo(() => {
const c = state()?.content
return c?.mimeType === "image/svg+xml"
})
const svgContent = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding === "base64") return base64Decode(c.content)
return c.content
})
const svgPreviewUrl = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
})
const imageDataUrl = createMemo(() => {
if (!isImage()) return
const c = state()?.content
return `data:${c?.mimeType};base64,${c?.content}`
})
const selectedLines = createMemo(() => {
const p = path()
if (!p) return null
@@ -1145,13 +1057,19 @@ export default function Page() {
return `L${sel.startLine}-${sel.endLine}`
})
const restoreScroll = () => {
const restoreScroll = (retries = 0) => {
const el = scroll
if (!el) return
const s = view()?.scroll(tab)
if (!s) return
// Wait for content to be scrollable - content may not have rendered yet
if (el.scrollHeight <= el.clientHeight && retries < 10) {
requestAnimationFrame(() => restoreScroll(retries + 1))
return
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
@@ -1196,6 +1114,17 @@ export default function Page() {
),
)
createEffect(
on(
() => tabs().active() === tab,
(active) => {
if (!active) return
if (!state()?.loaded) return
requestAnimationFrame(restoreScroll)
},
),
)
onCleanup(() => {
if (scrollFrame === undefined) return
cancelAnimationFrame(scrollFrame)
@@ -1230,6 +1159,37 @@ export default function Page() {
)}
</Show>
<Switch>
<Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40">
<img src={imageDataUrl()} alt={path()} class="max-w-full" />
</div>
</Match>
<Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4">
<Dynamic
component={codeComponent}
file={{
name: path() ?? "",
contents: svgContent() ?? "",
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
}}
overflow="scroll"
class="select-text"
/>
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded}>
<Dynamic
component={codeComponent}
+2 -1
View File
@@ -1,7 +1,8 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.224",
"version": "1.1.2",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
+1 -1
View File
@@ -7,7 +7,7 @@ export const config = {
// GitHub
github: {
repoUrl: "https://github.com/sst/opencode",
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "45K",
full: "45,000",
+1 -1
View File
@@ -26,7 +26,7 @@ export default function NotFound() {
<a href="/docs">Docs</a>
</div>
<div data-slot="action">
<a href="https://github.com/sst/opencode">GitHub</a>
<a href="https://github.com/anomalyco/opencode">GitHub</a>
</div>
<div data-slot="action">
<a href="/discord">Discord</a>
@@ -21,7 +21,7 @@ export async function GET({ params: { platform } }: APIEvent) {
const assetName = assetNames[platform]
if (!assetName) return new Response("Not Found", { status: 404 })
const resp = await fetch(`https://github.com/sst/opencode/releases/latest/download/${assetName}`, {
const resp = await fetch(`https://github.com/anomalyco/opencode/releases/latest/download/${assetName}`, {
cf: {
// in case gh releases has rate limits
cacheTtl: 60 * 60 * 24,
@@ -1,6 +1,6 @@
export async function GET() {
const response = await fetch(
"https://raw.githubusercontent.com/sst/opencode/refs/heads/dev/packages/sdk/openapi.json",
"https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/sdk/openapi.json",
)
const json = await response.json()
return json
+1 -1
View File
@@ -151,7 +151,7 @@ export default function Home() {
<a href="https://x.com/opencode">X.com</a>
</div>
<div data-slot="cell">
<a href="https://github.com/sst/opencode">GitHub</a>
<a href="https://github.com/anomalyco/opencode">GitHub</a>
</div>
<div data-slot="cell">
<a href="https://opencode.ai/discord">Discord</a>
@@ -0,0 +1 @@
CREATE INDEX `usage_time_created` ON `usage` (`workspace_id`,`time_created`);
File diff suppressed because it is too large Load Diff
@@ -281,6 +281,13 @@
"when": 1766946179892,
"tag": "0039_striped_forge",
"breakpoints": true
},
{
"idx": 40,
"version": "5",
"when": 1767584617316,
"tag": "0040_broken_gamora",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -1,9 +1,10 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.224",
"version": "1.1.2",
"private": true,
"type": "module",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -18,15 +18,17 @@ const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[
const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
const value5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1]
const value6 = lines.find((line) => line.startsWith("ZEN_MODELS6"))?.split("=")[1]
const value7 = lines.find((line) => line.startsWith("ZEN_MODELS7"))?.split("=")[1]
if (!value1) throw new Error("ZEN_MODELS1 not found")
if (!value2) throw new Error("ZEN_MODELS2 not found")
if (!value3) throw new Error("ZEN_MODELS3 not found")
if (!value4) throw new Error("ZEN_MODELS4 not found")
if (!value5) throw new Error("ZEN_MODELS5 not found")
if (!value6) throw new Error("ZEN_MODELS6 not found")
if (!value7) throw new Error("ZEN_MODELS7 not found")
// validate value
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5 + value6))
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5 + value6 + value7))
// update the secret
await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
@@ -35,3 +37,4 @@ await $`bun sst secret set ZEN_MODELS3 ${value3} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS4 ${value4} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS5 ${value5} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS6 ${value6} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS7 ${value7} --stage ${stage}`
+4 -1
View File
@@ -18,14 +18,16 @@ const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[
const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
const value5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1]
const value6 = lines.find((line) => line.startsWith("ZEN_MODELS6"))?.split("=")[1]
const value7 = lines.find((line) => line.startsWith("ZEN_MODELS7"))?.split("=")[1]
if (!value1) throw new Error("ZEN_MODELS1 not found")
if (!value2) throw new Error("ZEN_MODELS2 not found")
if (!value3) throw new Error("ZEN_MODELS3 not found")
if (!value4) throw new Error("ZEN_MODELS4 not found")
if (!value5) throw new Error("ZEN_MODELS5 not found")
if (!value6) throw new Error("ZEN_MODELS6 not found")
if (!value7) throw new Error("ZEN_MODELS7 not found")
// validate value
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5 + value6))
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5 + value6 + value7))
// update the secret
await $`bun sst secret set ZEN_MODELS1 ${value1}`
@@ -34,3 +36,4 @@ await $`bun sst secret set ZEN_MODELS3 ${value3}`
await $`bun sst secret set ZEN_MODELS4 ${value4}`
await $`bun sst secret set ZEN_MODELS5 ${value5}`
await $`bun sst secret set ZEN_MODELS6 ${value6}`
await $`bun sst secret set ZEN_MODELS7 ${value7}`
+11 -3
View File
@@ -16,18 +16,24 @@ const oldValue3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=
const oldValue4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
const oldValue5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1]
const oldValue6 = lines.find((line) => line.startsWith("ZEN_MODELS6"))?.split("=")[1]
const oldValue7 = lines.find((line) => line.startsWith("ZEN_MODELS7"))?.split("=")[1]
if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
if (!oldValue2) throw new Error("ZEN_MODELS2 not found")
if (!oldValue3) throw new Error("ZEN_MODELS3 not found")
if (!oldValue4) throw new Error("ZEN_MODELS4 not found")
if (!oldValue5) throw new Error("ZEN_MODELS5 not found")
if (!oldValue6) throw new Error("ZEN_MODELS6 not found")
if (!oldValue7) throw new Error("ZEN_MODELS7 not found")
// store the prettified json to a temp file
const filename = `models-${Date.now()}.json`
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
await tempFile.write(
JSON.stringify(JSON.parse(oldValue1 + oldValue2 + oldValue3 + oldValue4 + oldValue5 + oldValue6), null, 2),
JSON.stringify(
JSON.parse(oldValue1 + oldValue2 + oldValue3 + oldValue4 + oldValue5 + oldValue6 + oldValue7),
null,
2,
),
)
console.log("tempFile", tempFile.name)
@@ -37,13 +43,14 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
ZenData.validate(JSON.parse(newValue))
// update the secret
const chunk = Math.ceil(newValue.length / 6)
const chunk = Math.ceil(newValue.length / 7)
const newValue1 = newValue.slice(0, chunk)
const newValue2 = newValue.slice(chunk, chunk * 2)
const newValue3 = newValue.slice(chunk * 2, chunk * 3)
const newValue4 = newValue.slice(chunk * 3, chunk * 4)
const newValue5 = newValue.slice(chunk * 4, chunk * 5)
const newValue6 = newValue.slice(chunk * 5)
const newValue6 = newValue.slice(chunk * 5, chunk * 6)
const newValue7 = newValue.slice(chunk * 6)
await $`bun sst secret set ZEN_MODELS1 ${newValue1}`
await $`bun sst secret set ZEN_MODELS2 ${newValue2}`
@@ -51,3 +58,4 @@ await $`bun sst secret set ZEN_MODELS3 ${newValue3}`
await $`bun sst secret set ZEN_MODELS4 ${newValue4}`
await $`bun sst secret set ZEN_MODELS5 ${newValue5}`
await $`bun sst secret set ZEN_MODELS6 ${newValue6}`
await $`bun sst secret set ZEN_MODELS7 ${newValue7}`
+2 -1
View File
@@ -73,7 +73,8 @@ export namespace ZenData {
Resource.ZEN_MODELS3.value +
Resource.ZEN_MODELS4.value +
Resource.ZEN_MODELS5.value +
Resource.ZEN_MODELS6.value,
Resource.ZEN_MODELS6.value +
Resource.ZEN_MODELS7.value,
)
return ModelsSchema.parse(json)
})
@@ -1,4 +1,4 @@
import { bigint, boolean, int, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { bigint, boolean, index, int, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
@@ -55,5 +55,5 @@ export const UsageTable = mysqlTable(
cost: bigint("cost", { mode: "number" }).notNull(),
keyID: ulid("key_id"),
},
(table) => [...workspaceIndexes(table)],
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
)
+4
View File
@@ -122,6 +122,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS7": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+2 -1
View File
@@ -1,9 +1,10 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.224",
"version": "1.1.2",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit"
},
+4
View File
@@ -122,6 +122,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS7": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.224",
"version": "1.1.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -17,5 +17,6 @@
"scripts": {
"dev": "email preview emails/templates"
},
"type": "module"
"type": "module",
"license": "MIT"
}
+1
View File
@@ -1,6 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-resource",
"license": "MIT",
"dependencies": {
"@cloudflare/workers-types": "catalog:"
},
+4
View File
@@ -122,6 +122,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS7": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
-1
View File
@@ -13,7 +13,6 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<!-- Theme preload script - applies cached theme to avoid FOUC -->
<script id="oc-theme-preload-script">
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
+2 -1
View File
@@ -1,8 +1,9 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.0.224",
"version": "1.1.2",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo -b",
"predev": "bun ./scripts/predev.ts",
@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>ai.opencode.opencode</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>OpenCode</name>
<summary>Open source AI coding agent</summary>
<developer id="ly.anoma">
<name>Anomaly Innovations Inc.</name>
</developer>
<description>
<p>
OpenCode is an open source agent that helps you write and run code with any AI model.
</p>
</description>
<launchable type="desktop-id">ai.opencode.opencode.desktop</launchable>
<content_rating type="oars-1.1" />
<url type="bugtracker">https://github.com/anomalyco/opencode/issues</url>
<url type="homepage">https://opencode.ai</url>
<url type="vcs-browser">https://github.com/anomalyco/opencode</url>
<screenshots>
<screenshot type="default">
<image>https://opencode.ai/docs/_astro/screenshot.Bs5D4atL_ZvsvFu.webp</image>
</screenshot>
</screenshots>
<releases>
<release version="1.0.223" date="2026-01-01">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.223</url>
</release>
<release version="1.0.222" date="2026-01-01">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.222</url>
</release>
<release version="1.0.221" date="2025-12-31">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.221</url>
</release>
<release version="1.0.220" date="2025-12-31">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.220</url>
</release>
<release version="1.0.219" date="2025-12-31">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.219</url>
</release>
<release version="1.0.218" date="2025-12-30">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.218</url>
</release>
<release version="1.0.217" date="2025-12-30">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.217</url>
</release>
<release version="1.0.216" date="2025-12-30">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.216</url>
</release>
<release version="1.0.215" date="2025-12-30">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.215</url>
</release>
<release version="1.0.214" date="2025-12-30">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.214</url>
</release>
<release version="1.0.213" date="2025-12-30">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.213</url>
</release>
<release version="1.0.212" date="2025-12-30">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.212</url>
</release>
<release version="1.0.211" date="2025-12-30">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.211</url>
</release>
<release version="1.0.210" date="2025-12-30">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.210</url>
</release>
<release version="1.0.209" date="2025-12-30">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.209</url>
</release>
<release version="1.0.208" date="2025-12-29">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.208</url>
</release>
<release version="1.0.207" date="2025-12-29">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.207</url>
</release>
<release version="1.0.206" date="2025-12-28">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.206</url>
</release>
<release version="1.0.205" date="2025-12-28">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.205</url>
</release>
<release version="1.0.204" date="2025-12-27">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.204</url>
</release>
<release version="1.0.203" date="2025-12-26">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.203</url>
</release>
<release version="1.0.202" date="2025-12-26">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.202</url>
</release>
<release version="1.0.201" date="2025-12-25">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.201</url>
</release>
<release version="1.0.200" date="2025-12-25">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.200</url>
</release>
<release version="1.0.199" date="2025-12-25">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.199</url>
</release>
<release version="1.0.198" date="2025-12-24">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.198</url>
</release>
<release version="1.0.195" date="2025-12-24">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.195</url>
</release>
<release version="1.0.194" date="2025-12-24">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.194</url>
</release>
<release version="1.0.193" date="2025-12-23">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.193</url>
</release>
<release version="1.0.191" date="2025-12-23">
<url type="details">https://github.com/anomalyco/opencode/releases/tag/v1.0.191</url>
</release>
</releases>
</component>
@@ -15,12 +15,19 @@
"nsis": {
"installerIcon": "icons/prod/icon.ico"
}
},
"linux": {
"deb": {
"files": {
"/usr/share/metainfo/ai.opencode.opencode.metainfo.xml": "release/appstream.metainfo.xml"
}
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYwMDM5Nzg5OUMzOUExMDQKUldRRW9UbWNpWmNEOENYT01CV0lhOXR1UFhpaXJsK1Z3aU9lZnNtNzE0TDROWVMwVW9XQnFOelkK",
"endpoints": ["https://github.com/sst/opencode/releases/latest/download/latest.json"]
"endpoints": ["https://github.com/anomalyco/opencode/releases/latest/download/latest.json"]
}
}
}
+2 -2
View File
@@ -27,7 +27,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
let update: Update | null = null
const platform: Platform = {
platform: "tauri",
platform: "desktop",
version: pkg.version,
async openDirectoryPickerDialog(opts) {
@@ -198,7 +198,7 @@ render(() => {
return (
<PlatformProvider value={platform}>
{ostype() === "macos" && (
<div class="bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)}
<App />
</PlatformProvider>
+2 -1
View File
@@ -1,8 +1,9 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.224",
"version": "1.1.2",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev",
@@ -328,7 +328,7 @@ export default function () {
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
href="https://github.com/anomalyco/opencode"
target="_blank"
icon="github"
variant="ghost"
+4
View File
@@ -122,6 +122,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS7": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+7 -7
View File
@@ -1,36 +1,36 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.224"
version = "1.1.2"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
repository = "https://github.com/anomalyco/opencode"
[agent_servers.opencode]
name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.224/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/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.224/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.224/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/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.224/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/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.224/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+2 -1
View File
@@ -1,9 +1,10 @@
{
"name": "@opencode-ai/function",
"version": "1.0.224",
"version": "1.1.2",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
"license": "MIT",
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
+4
View File
@@ -122,6 +122,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS7": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+2 -2
View File
@@ -3,7 +3,7 @@
## Build/Test Commands
- **Install**: `bun install`
- **Run**: `bun run index.ts`
- **Run**: `bun run --conditions=browser ./src/index.ts`
- **Typecheck**: `bun run typecheck` (npm run typecheck)
- **Test**: `bun test` (runs all tests)
- **Single test**: `bun test test/tool/tool.test.ts` (specific test file)
@@ -24,4 +24,4 @@
- **Validation**: All inputs validated with Zod schemas
- **Logging**: Use `Log.create({ service: "name" })` pattern
- **Storage**: Use `Storage` namespace for persistence
- **API Client**: Go TUI communicates with TypeScript server via stainless SDK. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, ask the user to generate a new client SDK to proceed with client-side changes.
- **API Client**: The TypeScript TUI (built with SolidJS + OpenTUI) communicates with the OpenCode server using `@opencode-ai/sdk`. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, run `./script/generate.ts` to regenerate the SDK and related files.
+8 -6
View File
@@ -1,8 +1,9 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.224",
"version": "1.1.2",
"name": "opencode",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -25,6 +26,7 @@
"devDependencies": {
"@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -38,12 +40,11 @@
"@types/bun": "catalog:",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
"zod-to-json-schema": "3.24.5",
"@opencode-ai/script": "workspace:*"
"zod-to-json-schema": "3.24.5"
},
"dependencies": {
"@actions/core": "1.11.1",
@@ -80,11 +81,12 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.67",
"@opentui/solid": "0.1.67",
"@opentui/core": "0.1.68",
"@opentui/solid": "0.1.68",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
+10 -10
View File
@@ -22,17 +22,17 @@ if (!Script.preview) {
"options=('!debug' '!strip')",
"pkgrel=1",
"pkgdesc='The AI coding agent built for the terminal.'",
"url='https://github.com/sst/opencode'",
"url='https://github.com/anomalyco/opencode'",
"arch=('aarch64' 'x86_64')",
"license=('MIT')",
"provides=('opencode')",
"conflicts=('opencode')",
"depends=('ripgrep')",
"",
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`,
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`,
`sha256sums_aarch64=('${arm64Sha}')`,
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`,
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`,
`sha256sums_x86_64=('${x64Sha}')`,
"",
"package() {",
@@ -52,7 +52,7 @@ if (!Script.preview) {
"options=('!debug' '!strip')",
"pkgrel=1",
"pkgdesc='The AI coding agent built for the terminal.'",
"url='https://github.com/sst/opencode'",
"url='https://github.com/anomalyco/opencode'",
"arch=('aarch64' 'x86_64')",
"license=('MIT')",
"provides=('opencode')",
@@ -60,7 +60,7 @@ if (!Script.preview) {
"depends=('ripgrep')",
"makedepends=('git' 'bun' 'go')",
"",
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`,
`source=("opencode-\${pkgver}.tar.gz::https://github.com/anomalyco/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`,
`sha256sums=('SKIP')`,
"",
"build() {",
@@ -133,14 +133,14 @@ if (!Script.preview) {
"# This file was generated by GoReleaser. DO NOT EDIT.",
"class Opencode < Formula",
` desc "The AI coding agent built for the terminal."`,
` homepage "https://github.com/sst/opencode"`,
` homepage "https://github.com/anomalyco/opencode"`,
` version "${Script.version.split("-")[0]}"`,
"",
` depends_on "ripgrep"`,
"",
" on_macos do",
" if Hardware::CPU.intel?",
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-darwin-x64.zip"`,
` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-darwin-x64.zip"`,
` sha256 "${macX64Sha}"`,
"",
" def install",
@@ -148,7 +148,7 @@ if (!Script.preview) {
" end",
" end",
" if Hardware::CPU.arm?",
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-darwin-arm64.zip"`,
` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-darwin-arm64.zip"`,
` sha256 "${macArm64Sha}"`,
"",
" def install",
@@ -159,14 +159,14 @@ if (!Script.preview) {
"",
" on_linux do",
" if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?",
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-x64.tar.gz"`,
` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-linux-x64.tar.gz"`,
` sha256 "${x64Sha}"`,
" def install",
' bin.install "opencode"',
" end",
" end",
" if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?",
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-arm64.tar.gz"`,
` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-linux-arm64.tar.gz"`,
` sha256 "${arm64Sha}"`,
" def install",
' bin.install "opencode"',
+1 -1
View File
@@ -62,7 +62,7 @@ if (!Script.preview) {
}
}
const image = "ghcr.io/sst/opencode"
const image = "ghcr.io/anomalyco/opencode"
const platforms = "linux/amd64,linux/arm64"
const tags = [`${image}:${Script.version}`, `${image}:latest`]
const tagFlags = tags.flatMap((t) => ["-t", t])
+46 -8
View File
@@ -34,13 +34,9 @@ import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2
export namespace ACP {
const log = Log.create({ service: "acp-agent" })
export async function init({ sdk }: { sdk: OpencodeClient }) {
const model = await defaultModel({ sdk })
export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
return {
create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
if (!fullConfig.defaultModel) {
fullConfig.defaultModel = model
}
return new Agent(connection, fullConfig)
},
}
@@ -988,8 +984,10 @@ export namespace ACP {
const configured = config.defaultModel
if (configured) return configured
const model = await sdk.config
.get({ directory: cwd }, { throwOnError: true })
const directory = cwd ?? process.cwd()
const specified = await sdk.config
.get({ directory }, { throwOnError: true })
.then((resp) => {
const cfg = resp.data
if (!cfg || !cfg.model) return undefined
@@ -1004,7 +1002,47 @@ export namespace ACP {
return undefined
})
return model ?? { providerID: "opencode", modelID: "big-pickle" }
const providers = await sdk.config
.providers({ directory }, { throwOnError: true })
.then((x) => x.data?.providers ?? [])
.catch((error) => {
log.error("failed to list providers for default model", { error })
return []
})
if (specified && providers.length) {
const provider = providers.find((p) => p.id === specified.providerID)
if (provider && provider.models[specified.modelID]) return specified
}
if (specified && !providers.length) return specified
const opencodeProvider = providers.find((p) => p.id === "opencode")
if (opencodeProvider) {
if (opencodeProvider.models["big-pickle"]) {
return { providerID: "opencode", modelID: "big-pickle" }
}
const [best] = Provider.sort(Object.values(opencodeProvider.models))
if (best) {
return {
providerID: best.providerID,
modelID: best.id,
}
}
}
const models = providers.flatMap((p) => Object.values(p.models))
const [best] = Provider.sort(models)
if (best) {
return {
providerID: best.providerID,
modelID: best.id,
}
}
if (specified) return specified
return { providerID: "opencode", modelID: "big-pickle" }
}
function parseUri(
+7
View File
@@ -47,6 +47,13 @@ export namespace Agent {
"*": "allow",
doom_loop: "ask",
external_directory: "ask",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
},
})
const user = PermissionNext.fromConfig(cfg.permission ?? {})
+54 -12
View File
@@ -396,7 +396,7 @@ jobs:
uses: actions/checkout@v4
- name: Run opencode
uses: sst/opencode/github@latest${envStr}
uses: anomalyco/opencode/github@latest${envStr}
with:
model: ${provider}/${model}`,
)
@@ -994,12 +994,16 @@ export const GithubRunCommand = cmd({
console.log("Configuring git...")
const config = "http.https://github.com/.extraheader"
const ret = await $`git config --local --get ${config}`
gitConfig = ret.stdout.toString().trim()
// actions/checkout@v6 no longer stores credentials in .git/config,
// so this may not exist - use nothrow() to handle gracefully
const ret = await $`git config --local --get ${config}`.nothrow()
if (ret.exitCode === 0) {
gitConfig = ret.stdout.toString().trim()
await $`git config --local --unset-all ${config}`
}
const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
await $`git config --local --unset-all ${config}`
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
await $`git config --global user.name "${AGENT_USERNAME}"`
await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"`
@@ -1233,17 +1237,55 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
async function createPR(base: string, branch: string, title: string, body: string) {
console.log("Creating pull request...")
const pr = await octoRest.rest.pulls.create({
owner,
repo,
head: branch,
base,
title,
body,
})
// Check if an open PR already exists for this head→base combination
// This handles the case where the agent created a PR via gh pr create during its run
try {
const existing = await withRetry(() =>
octoRest.rest.pulls.list({
owner,
repo,
head: `${owner}:${branch}`,
base,
state: "open",
}),
)
if (existing.data.length > 0) {
console.log(`PR #${existing.data[0].number} already exists for branch ${branch}`)
return existing.data[0].number
}
} catch (e) {
// If the check fails, proceed to create - we'll get a clear error if a PR already exists
console.log(`Failed to check for existing PR: ${e}`)
}
const pr = await withRetry(() =>
octoRest.rest.pulls.create({
owner,
repo,
head: branch,
base,
title,
body,
}),
)
return pr.data.number
}
async function withRetry<T>(fn: () => Promise<T>, retries = 1, delayMs = 5000): Promise<T> {
try {
return await fn()
} catch (e) {
if (retries > 0) {
console.log(`Retrying after ${delayMs}ms...`)
await Bun.sleep(delayMs)
return withRetry(fn, retries - 1, delayMs)
}
throw e
}
}
function footer(opts?: { image?: boolean }) {
const image = (() => {
if (!shareId) return ""
+27 -11
View File
@@ -36,6 +36,18 @@ function getAuthStatusText(status: MCP.AuthStatus): string {
}
}
type McpEntry = NonNullable<Config.Info["mcp"]>[string]
type McpConfigured = Config.Mcp
function isMcpConfigured(config: McpEntry): config is McpConfigured {
return typeof config === "object" && config !== null && "type" in config
}
type McpRemote = Extract<McpConfigured, { type: "remote" }>
function isMcpRemote(config: McpEntry): config is McpRemote {
return isMcpConfigured(config) && config.type === "remote"
}
export const McpCommand = cmd({
command: "mcp",
builder: (yargs) =>
@@ -64,15 +76,19 @@ export const McpListCommand = cmd({
const mcpServers = config.mcp ?? {}
const statuses = await MCP.status()
if (Object.keys(mcpServers).length === 0) {
const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] =>
isMcpConfigured(entry[1]),
)
if (servers.length === 0) {
prompts.log.warn("No MCP servers configured")
prompts.outro("Add servers with: opencode mcp add")
return
}
for (const [name, serverConfig] of Object.entries(mcpServers)) {
for (const [name, serverConfig] of servers) {
const status = statuses[name]
const hasOAuth = serverConfig.type === "remote" && !!serverConfig.oauth
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
const hasStoredTokens = await MCP.hasStoredTokens(name)
let statusIcon: string
@@ -110,7 +126,7 @@ export const McpListCommand = cmd({
)
}
prompts.outro(`${Object.keys(mcpServers).length} server(s)`)
prompts.outro(`${servers.length} server(s)`)
},
})
},
@@ -138,7 +154,7 @@ export const McpAuthCommand = cmd({
// Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
const oauthServers = Object.entries(mcpServers).filter(
([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false,
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
if (oauthServers.length === 0) {
@@ -163,7 +179,7 @@ export const McpAuthCommand = cmd({
const authStatus = await MCP.getAuthStatus(name)
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = cfg.type === "remote" ? cfg.url : ""
const url = cfg.url
return {
label: `${icon} ${name} (${statusText})`,
value: name,
@@ -187,8 +203,8 @@ export const McpAuthCommand = cmd({
return
}
if (serverConfig.type !== "remote" || serverConfig.oauth === false) {
prompts.log.error(`MCP server ${serverName} does not support OAuth (oauth is disabled)`)
if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) {
prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`)
prompts.outro("Done")
return
}
@@ -263,7 +279,7 @@ export const McpAuthListCommand = cmd({
// Get OAuth-capable servers
const oauthServers = Object.entries(mcpServers).filter(
([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false,
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
if (oauthServers.length === 0) {
@@ -276,7 +292,7 @@ export const McpAuthListCommand = cmd({
const authStatus = await MCP.getAuthStatus(name)
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = serverConfig.type === "remote" ? serverConfig.url : ""
const url = serverConfig.url
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
}
@@ -506,7 +522,7 @@ export const McpDebugCommand = cmd({
return
}
if (serverConfig.type !== "remote") {
if (!isMcpRemote(serverConfig)) {
prompts.log.error(`MCP server ${serverName} is not a remote server`)
prompts.outro("Done")
return
+6
View File
@@ -87,6 +87,10 @@ export const RunCommand = cmd({
type: "number",
describe: "port for the local server (defaults to random port if no value provided)",
})
.option("variant", {
type: "string",
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
})
},
handler: async (args) => {
let message = [...args.message, ...(args["--"] || [])]
@@ -254,6 +258,7 @@ export const RunCommand = cmd({
model: args.model,
command: args.command,
arguments: message,
variant: args.variant,
})
} else {
const modelParam = args.model ? Provider.parseModel(args.model) : undefined
@@ -261,6 +266,7 @@ export const RunCommand = cmd({
sessionID,
agent: resolvedAgent,
model: modelParam,
variant: args.variant,
parts: [...fileParts, { type: "text", text: message }],
})
}
+11 -3
View File
@@ -96,7 +96,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
})
}
export function tui(input: { url: string; args: Args; onExit?: () => Promise<void> }) {
export function tui(input: { url: string; args: Args; directory?: string; onExit?: () => Promise<void> }) {
// promise to prevent immediate exit
return new Promise<void>(async (resolve) => {
const mode = await getTerminalBackgroundColor()
@@ -116,7 +116,7 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
<KVProvider>
<ToastProvider>
<RouteProvider>
<SDKProvider url={input.url}>
<SDKProvider url={input.url} directory={input.directory}>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
@@ -412,6 +412,7 @@ function App() {
{
title: "Switch theme",
value: "theme.switch",
keybind: "theme_list",
onSelect: () => {
dialog.replace(() => <DialogThemeList />)
},
@@ -549,6 +550,13 @@ function App() {
})
})
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
route.navigate({
type: "session",
sessionID: evt.properties.sessionID,
})
})
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" })
@@ -648,7 +656,7 @@ function ErrorComponent(props: {
})
const [copied, setCopied] = createSignal(false)
const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml")
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
// Choose safe fallback colors per mode since theme context may not be available
const isLight = props.mode === "light"
@@ -22,9 +22,11 @@ export const AttachCommand = cmd({
}),
handler: async (args) => {
if (args.dir) process.chdir(args.dir)
const directory = process.cwd()
await tui({
url: args.url,
args: { sessionID: args.session },
directory,
})
},
})
@@ -17,7 +17,6 @@ const PROVIDER_PRIORITY: Record<string, number> = {
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
}
export function createDialogProviderOptions() {
@@ -2,13 +2,14 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename"
import { useKV } from "../context/kv"
import { createDebouncedSignal } from "../util/signal"
import "opentui-spinner/solid"
export function DialogSessionList() {
@@ -20,6 +21,13 @@ export function DialogSessionList() {
const kv = useKV()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [searchResults] = createResource(search, async (query) => {
if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? []
})
const deleteKeybind = "ctrl+d"
@@ -27,9 +35,11 @@ export function DialogSessionList() {
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
const sessions = createMemo(() => searchResults() ?? sync.data.session)
const options = createMemo(() => {
const today = new Date().toDateString()
return sync.data.session
return sessions()
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
@@ -54,11 +64,6 @@ export function DialogSessionList() {
) : undefined,
}
})
.slice(0, 150)
})
createEffect(() => {
console.log("session count", sync.data.session.length)
})
onMount(() => {
@@ -69,7 +74,9 @@ export function DialogSessionList() {
<DialogSelect
title="Sessions"
options={options()}
skipFilter={true}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
@@ -231,6 +231,40 @@ export function Autocomplete(props: {
},
)
const mcpResources = createMemo(() => {
if (!store.visible || store.visible === "/") return []
const options: AutocompleteOption[] = []
const width = props.anchor().width - 4
for (const res of Object.values(sync.data.mcp_resource)) {
options.push({
display: Locale.truncateMiddle(`${res.name} (${res.uri})`, width),
description: res.description,
onSelect: () => {
insertPart(res.name, {
type: "file",
mime: res.mimeType ?? "text/plain",
filename: res.name,
url: res.uri,
source: {
type: "resource",
text: {
start: 0,
end: 0,
value: "",
},
clientName: res.client,
uri: res.uri,
},
})
},
})
}
return options
})
const agents = createMemo(() => {
const agents = sync.data.agent
return agents
@@ -416,7 +450,7 @@ export function Autocomplete(props: {
const commandsValue = commands()
const mixed: AutocompleteOption[] = (
store.visible === "@" ? [...agentsValue, ...(filesValue || [])] : [...commandsValue]
store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
).filter((x) => x.disabled !== true)
const currentFilter = filter()
@@ -1,4 +1,4 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core"
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import { useLocal } from "@tui/context/local"
@@ -10,7 +10,6 @@ import { useSync } from "@tui/context/sync"
import { Identifier } from "@/id/id"
import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
@@ -30,6 +29,7 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
export type PromptProps = {
sessionID?: string
@@ -53,61 +53,6 @@ export type PromptRef = {
const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
const TEXTAREA_ACTIONS = [
"submit",
"newline",
"move-left",
"move-right",
"move-up",
"move-down",
"select-left",
"select-right",
"select-up",
"select-down",
"line-home",
"line-end",
"select-line-home",
"select-line-end",
"visual-line-home",
"visual-line-end",
"select-visual-line-home",
"select-visual-line-end",
"buffer-home",
"buffer-end",
"select-buffer-home",
"select-buffer-end",
"delete-line",
"delete-to-line-end",
"delete-to-line-start",
"backspace",
"delete",
"undo",
"redo",
"word-forward",
"word-backward",
"select-word-forward",
"select-word-backward",
"delete-word-forward",
"delete-word-backward",
] as const
function mapTextareaKeybindings(
keybinds: Record<string, Keybind.Info[]>,
action: (typeof TEXTAREA_ACTIONS)[number],
): KeyBinding[] {
const configKey = `input_${action.replace(/-/g, "_")}`
const bindings = keybinds[configKey]
if (!bindings) return []
return bindings.map((binding) => ({
name: binding.name,
ctrl: binding.ctrl || undefined,
meta: binding.meta || undefined,
shift: binding.shift || undefined,
super: binding.super || undefined,
action,
}))
}
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
@@ -139,15 +84,7 @@ export function Prompt(props: PromptProps) {
}
}
const textareaKeybindings = createMemo(() => {
const keybinds = keybind.all
return [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)),
] satisfies KeyBinding[]
})
const textareaKeybindings = useTextareaKeybindings()
const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = syntax().getStyleId("extmark.agent")!
@@ -812,7 +749,7 @@ export function Prompt(props: PromptProps) {
>
<box
paddingLeft={2}
paddingRight={1}
paddingRight={2}
paddingTop={1}
flexShrink={0}
backgroundColor={theme.backgroundElement}
@@ -0,0 +1,73 @@
import { createMemo } from "solid-js"
import type { KeyBinding } from "@opentui/core"
import { useKeybind } from "../context/keybind"
import { Keybind } from "@/util/keybind"
const TEXTAREA_ACTIONS = [
"submit",
"newline",
"move-left",
"move-right",
"move-up",
"move-down",
"select-left",
"select-right",
"select-up",
"select-down",
"line-home",
"line-end",
"select-line-home",
"select-line-end",
"visual-line-home",
"visual-line-end",
"select-visual-line-home",
"select-visual-line-end",
"buffer-home",
"buffer-end",
"select-buffer-home",
"select-buffer-end",
"delete-line",
"delete-to-line-end",
"delete-to-line-start",
"backspace",
"delete",
"undo",
"redo",
"word-forward",
"word-backward",
"select-word-forward",
"select-word-backward",
"delete-word-forward",
"delete-word-backward",
] as const
function mapTextareaKeybindings(
keybinds: Record<string, Keybind.Info[]>,
action: (typeof TEXTAREA_ACTIONS)[number],
): KeyBinding[] {
const configKey = `input_${action.replace(/-/g, "_")}`
const bindings = keybinds[configKey]
if (!bindings) return []
return bindings.map((binding) => ({
name: binding.name,
ctrl: binding.ctrl || undefined,
meta: binding.meta || undefined,
shift: binding.shift || undefined,
super: binding.super || undefined,
action,
}))
}
export function useTextareaKeybindings() {
const keybind = useKeybind()
return createMemo(() => {
const keybinds = keybind.all
return [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)),
] satisfies KeyBinding[]
})
}
@@ -92,7 +92,7 @@ export const TIPS = [
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info.",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling.",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight}).",
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use.",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use.",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models.",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing.",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs.",
@@ -5,11 +5,12 @@ import { batch, onCleanup, onMount } from "solid-js"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { url: string }) => {
init: (props: { url: string; directory?: string }) => {
const abort = new AbortController()
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
directory: props.directory,
})
const emitter = createGlobalEmitter<{
@@ -10,6 +10,7 @@ import type {
PermissionRequest,
LspStatus,
McpStatus,
McpResource,
FormatterStatus,
SessionStatus,
ProviderListResponse,
@@ -62,6 +63,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {
[key: string]: McpStatus
}
mcp_resource: {
[key: string]: McpResource
}
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
path: Path
@@ -87,6 +91,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
part: {},
lsp: [],
mcp: {},
mcp_resource: {},
formatter: [],
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
@@ -264,8 +269,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
async function bootstrap() {
console.log("bootstrapping")
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
.list()
.list({ start: start })
.then((x) => setStore("session", reconcile((x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))))
// blocking - include session.list when continuing a session
@@ -295,6 +301,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
sdk.client.session.status().then((x) => {
setStore("session_status", reconcile(x.data!))
@@ -407,25 +407,45 @@ async function getCustomThemes() {
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const palette = colors.palette.filter((x) => x !== null).map((x) => RGBA.fromHex(x))
const isDark = mode == "dark"
const col = (i: number) => {
const value = colors.palette[i]
if (value) return RGBA.fromHex(value)
return ansiToRgba(i)
}
const tint = (base: RGBA, overlay: RGBA, alpha: number) => {
const r = base.r + (overlay.r - base.r) * alpha
const g = base.g + (overlay.g - base.g) * alpha
const b = base.b + (overlay.b - base.b) * alpha
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
// Generate gray scale based on terminal background
const grays = generateGrayScale(bg, isDark)
const textMuted = generateMutedTextColor(bg, isDark)
// ANSI color references
const ansiColors = {
black: palette[0],
red: palette[1],
green: palette[2],
yellow: palette[3],
blue: palette[4],
magenta: palette[5],
cyan: palette[6],
white: palette[7],
black: col(0),
red: col(1),
green: col(2),
yellow: col(3),
blue: col(4),
magenta: col(5),
cyan: col(6),
white: col(7),
redBright: col(9),
greenBright: col(10),
}
const diffAlpha = isDark ? 0.22 : 0.14
const diffAddedBg = tint(bg, ansiColors.green, diffAlpha)
const diffRemovedBg = tint(bg, ansiColors.red, diffAlpha)
const diffAddedLineNumberBg = tint(grays[3], ansiColors.green, diffAlpha)
const diffRemovedLineNumberBg = tint(grays[3], ansiColors.red, diffAlpha)
return {
theme: {
// Primary colors using ANSI
@@ -460,14 +480,14 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
diffRemoved: ansiColors.red,
diffContext: grays[7],
diffHunkHeader: grays[7],
diffHighlightAdded: ansiColors.green,
diffHighlightRemoved: ansiColors.red,
diffAddedBg: grays[2],
diffRemovedBg: grays[2],
diffHighlightAdded: ansiColors.greenBright,
diffHighlightRemoved: ansiColors.redBright,
diffAddedBg,
diffRemovedBg,
diffContextBg: grays[1],
diffLineNumber: grays[6],
diffAddedLineNumberBg: grays[3],
diffRemovedLineNumberBg: grays[3],
diffAddedLineNumberBg,
diffRemovedLineNumberBg,
// Markdown colors
markdownText: fg,
@@ -37,4 +37,10 @@ export const TuiEvent = {
duration: z.number().default(5000).optional().describe("Duration in milliseconds"),
}),
),
SessionSelect: BusEvent.define(
"tui.session.select",
z.object({
sessionID: z.string().regex(/^ses/).describe("Session ID to navigate to"),
}),
),
}
@@ -120,7 +120,7 @@ export function Home() {
<span style={{ fg: theme.error }}>⊙ </span>
</Match>
<Match when={true}>
<span style={{ fg: theme.success }}>⊙ </span>
<span style={{ fg: connectedMcpCount() > 0 ? theme.success : theme.textMuted }}>⊙ </span>
</Match>
</Switch>
{connectedMcpCount()} MCP
@@ -64,7 +64,7 @@ export function Footer() {
</text>
</Show>
<text fg={theme.text}>
<span style={{ fg: theme.success }}>•</span> {lsp().length} LSP
<span style={{ fg: lsp().length > 0 ? theme.success : theme.textMuted }}>•</span> {lsp().length} LSP
</text>
<Show when={mcp()}>
<text fg={theme.text}>

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