Compare commits

..

47 Commits

Author SHA1 Message Date
opencode 29b3e40ddb release: v1.0.19 2025-11-04 04:01:22 +00:00
Dax Raad c49f5939a2 tui: fix model selection for models with nested paths
Users can now select models with multiple slashes like 'openrouter/google/gemini-2.5-pro'
in the TUI. Previously the TUI would only parse the first two parts of the model
path, showing 'Invalid model openrouter/google' for nested models.
2025-11-03 19:52:43 -05:00
kaanmertkoc 63862b1609 feat: implement stats command (#3832) 2025-11-03 18:41:30 -06:00
Mikhail Wahib 1cf1e88b52 fix: print the modified keybind for command_list (#3859) 2025-11-03 18:29:23 -06:00
Jay V d06afd87e5 ignore: lander 2025-11-03 18:17:32 -05:00
Frank 9fb6e81007 wip: zen 2025-11-03 17:30:18 -05:00
Adi Yeroslav 3ac82227f1 fix: update logo (#3833) 2025-11-03 16:18:38 -06:00
Aiden Cline c1f9249c84 ci: auto label web 2025-11-03 16:17:53 -06:00
Dax Raad 9bb66946db fix: correct dirs parameter type in file search 2025-11-03 17:10:31 -05:00
Dax Raad adcdbbddc7 tui: remove duplicate copy message command entry 2025-11-03 17:10:20 -05:00
Dax Raad 662435c5bb ci: stuff 2025-11-03 22:01:30 +00:00
opencode 36c1a05eaa release: v1.0.18 2025-11-03 22:01:30 +00:00
Dax Raad 5708e3bf1e ci: tweak 2025-11-03 16:56:41 -05:00
Dax Raad 0da1ed3fc8 tui: add copy last assistant message to session menu 2025-11-03 16:47:18 -05:00
Adam d5179c8b63 wip: desktop work 2025-11-03 15:42:39 -06:00
Adam bd0a4f7bbe wip: desktop work 2025-11-03 15:42:10 -06:00
Adam 3d43214075 wip: desktop work 2025-11-03 15:42:10 -06:00
Dax Raad 178a14ce3e fix dirs query param 2025-11-03 16:35:55 -05:00
Timo Clasen 8e1010dc3f feat(TUI): don't show /share hint if sharing is disabled (#3835) 2025-11-03 15:30:55 -06:00
Adi Yeroslav 9c82f1f5e9 fix: session rename functionality (#3840) 2025-11-03 15:26:30 -06:00
Dmytro Tiapukhin e5a651eef7 fix: better mcp sanitization (#3842)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-03 15:19:49 -06:00
Dax Raad d26605aa56 tui: add support for Ctrl+_ key combination in keybind parser 2025-11-03 16:16:09 -05:00
opencode 5cc0d337b1 release: v1.0.17 2025-11-03 21:14:52 +00:00
Dax Raad 902763b47d web command 2025-11-03 16:10:23 -05:00
Aiden Cline 55d07a139c fix: mcp error (#3847) 2025-11-03 15:04:53 -06:00
Frank 05232ead93 zen: wip 2025-11-03 15:44:06 -05:00
Tyler Gannon 7652a96064 fix: wait for stdout to flush in generate command (#3821) 2025-11-03 14:05:48 -06:00
Frank 901aae09f7 zen: filter out alpha models 2025-11-03 15:04:59 -05:00
opencode f95799f17c release: v1.0.16 2025-11-03 17:08:32 +00:00
Dax Raad 99a6c5e44d regen sdk 2025-11-03 11:55:19 -05:00
Dax Raad 07bb75f086 core: add optional dirs parameter to file search API
Allow users to exclude directories from file search results by setting dirs=false parameter in /find/file endpoint
2025-11-03 11:53:41 -05:00
Frank 66eb846e6f zen: wip 2025-11-03 11:30:53 -05:00
Adam 34f11c699e wip: desktop work 2025-11-03 08:29:13 -06:00
Adam 7a32fec008 wip: desktop work 2025-11-03 08:29:13 -06:00
James Alexander 37a6b5177e Add unit tests for util functions: iife, lazy, timeout (#3791) 2025-11-03 09:24:45 -05:00
Haris Gušić 573ffe186b fix(tui): Show correct keybind in session delete confirmation message (#3805) 2025-11-03 09:22:05 -05:00
Alex Knight 0f7ff3fcb1 Log share link immediately after session creation (#3811) 2025-11-03 09:21:43 -05:00
frankdierolf 2c3aa330b9 fix: correct clipboard image encoding and binary handling (#3817) 2025-11-03 09:21:13 -05:00
Pranshu Raj 47b2fb79dc docs: add session_child_cycle and session_child_cycle_reverse keybinds (#3807) 2025-11-03 09:20:35 -05:00
Sebastian Herrlinger 6deaf54bb3 use new opentui getTextRange method and Bun.stringWidth instead of value.length to mitigate issues like #3734 2025-11-03 15:15:55 +01:00
GitHub Action d549cd3213 ignore: update download stats 2025-11-03 2025-11-03 12:04:38 +00:00
Ivan Starkov 93e52f7ecf feat: Enhance task display with [subagent type] (#3772) 2025-11-03 01:09:31 -06:00
Aiden Cline 88f12b0822 core: prevent TypeError when error handling encounters non-object errors
When API errors like token limit exceeded errors are passed as strings to error checking methods, the 'in' operator would throw a TypeError. This fix adds a type guard to check that the input is an object before attempting to access its properties, allowing proper error classification even when encountering unexpected error formats from providers.
2025-11-02 23:38:56 -06:00
Zeldris 54af7f9e18 docs: use brew official formula (#3733) 2025-11-02 21:00:23 -06:00
Dax Raad be685e95a3 docs 2025-11-03 01:57:36 +00:00
Dax Raad dc2ab75fca ci: eventualy consistency 2025-11-03 01:57:36 +00:00
opencode f1324e886f release: v1.0.15 2025-11-03 01:57:36 +00:00
78 changed files with 5852 additions and 943 deletions
+24 -2
View File
@@ -11,7 +11,7 @@ jobs:
contents: read
issues: write
steps:
- name: Add opentui label
- name: Auto-label and assign issues
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -20,10 +20,32 @@ jobs:
const title = issue.title;
const description = issue.body || '';
// Check for web/desktop keywords
const webPattern = /\b(web|desktop)\b/i;
const isWebRelated = webPattern.test(title) || webPattern.test(description);
// Check for version patterns like v1.0.x or 1.0.x
const versionPattern = /[v]?1\.0\./i;
const isVersionRelated = versionPattern.test(title) || versionPattern.test(description);
if (versionPattern.test(title) || versionPattern.test(description)) {
if (isWebRelated) {
// Add web label
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['web']
});
// Assign to adamdotdevin
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
assignees: ['adamdotdevin']
});
} else if (isVersionRelated) {
// Only add opentui if NOT web-related
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
+1 -1
View File
@@ -28,7 +28,7 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install sst/tap/opencode # macOS and Linux
brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
```
+1
View File
@@ -128,3 +128,4 @@
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
+21 -21
View File
@@ -39,7 +39,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.14",
"version": "1.0.19",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -66,7 +66,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.14",
"version": "1.0.19",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -90,7 +90,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.14",
"version": "1.0.19",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -111,7 +111,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.14",
"version": "1.0.19",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -150,7 +150,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.14",
"version": "1.0.19",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -166,7 +166,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.14",
"version": "1.0.19",
"bin": {
"opencode": "./bin/opencode",
},
@@ -184,8 +184,8 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.32",
"@opentui/solid": "0.1.32",
"@opentui/core": "0.1.33",
"@opentui/solid": "0.1.33",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -243,7 +243,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.14",
"version": "1.0.19",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -263,7 +263,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.14",
"version": "1.0.19",
"devDependencies": {
"@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:",
@@ -274,7 +274,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.14",
"version": "1.0.19",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -287,7 +287,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.14",
"version": "1.0.19",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -317,7 +317,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.14",
"version": "1.0.19",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -961,21 +961,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.32", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.32", "@opentui/core-darwin-x64": "0.1.32", "@opentui/core-linux-arm64": "0.1.32", "@opentui/core-linux-x64": "0.1.32", "@opentui/core-win32-arm64": "0.1.32", "@opentui/core-win32-x64": "0.1.32", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6Ms1Gybyvl3Rt4k8OdA2c/5YlhobICcXjF5mn4k7tWujFnrBTm441G8k02pdIUffy7fD7dsouq12gfAVmSBmvA=="],
"@opentui/core": ["@opentui/core@0.1.33", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.33", "@opentui/core-darwin-x64": "0.1.33", "@opentui/core-linux-arm64": "0.1.33", "@opentui/core-linux-x64": "0.1.33", "@opentui/core-win32-arm64": "0.1.33", "@opentui/core-win32-x64": "0.1.33", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vwHdrPIqnsY6YnG2JTNhenHSsx+HUPYrQTBZdmEfCj9ROGVzKgUKbSDH1xGK2OtSNRb2KVBg4XaMpq0bie6afQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.32", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5yvhJEsXnZGS/q2jIfz97eA4wHOJyF/zTvJL6ykvqjlXwW+bOQ8S7WcpBShR2gf+49Exak3cO+XB16yMWmCyEw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.33", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JBvzcP2V7fT9KxFAMenHRd/t72qPP5IL5kzge2uok1T7t2nw3Wa+CWI5s6FYP42p2b1W9qZkv5Fno5gA7OAYuQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.32", "", { "os": "darwin", "cpu": "x64" }, "sha512-DKEA3kYvFuj5C4i1N1ck+VEqYH1iCc1O958iGrc5r++jGcP0osKKEA5qSWbSlEy+iflr7Oydr350Aqyyt7J/pA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.33", "", { "os": "darwin", "cpu": "x64" }, "sha512-x7DY6VCkAky10z/2o4UkkuNW/nIvoX7uAh3dJOHWZCLbiKywSFvFk3QZVVcH5BMk4tOOophYTzika4s4HpaeMg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.32", "", { "os": "linux", "cpu": "arm64" }, "sha512-02rgp+Rq21hg3MhQIo8guvHNGuerJgdGSWqPsUs7HwsHLL1yD8ndMxdZxOvyHNsEjzrzklQqPishHZ97QbAVYQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.33", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBc1EdkVxsLBtqGjXM2BYpBJLa57ogcrSADSZbc5cQkPu0muSGzUwBbVnVZJUjWEfk6n5jcd4dDmLezVoQga0A=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.32", "", { "os": "linux", "cpu": "x64" }, "sha512-+ek+EYyJKC9xxVeqD14XlBYaaN7Xm45PGv7pviuqZMwJn+G6v6JxwfUNASOr/KT0N1BZdcDGp9EGToYGXwjsQg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.33", "", { "os": "linux", "cpu": "x64" }, "sha512-3oVL5mrLlKLUc1lc4v7xS3BJ9N7PnnimbGwAvlnVpfaAygotAs1XkPcjsUe6ItMnSJyi0FWiDHUE2+GiDtM5Nw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.32", "", { "os": "win32", "cpu": "arm64" }, "sha512-k9vt+jBrrWAKOWmOC02G4S8V+1iftsq6a+8+Lt9Vc6GdXNTWIKCooB5FPAoLaQeb9TXKjK1WJFgibEXo3Q9XXQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.33", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q68v7wssE+r0OG1KIGfi7m3fnu8KOK4ZNg9ML6EwE47VF9/bqgUe+6fPiXh5mmHzTwof7nAOdXCf052av5/upQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.32", "", { "os": "win32", "cpu": "x64" }, "sha512-aLUYKZVMyyyN2A0d5ETbt4ktTb6GCp4PXYHijhOXy7QPvg779H8fy7dpGUHZYEJdwAdGrIvi/y6SKn9FmuXisA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.33", "", { "os": "win32", "cpu": "x64" }, "sha512-PvuchmUnbMCUXXMzfle/WTzhNGIdJ6RGCCoclx3YVUyNUVuUicPf42OEV+td2m81/Hr3CgcLn98HYX1TLIzPrw=="],
"@opentui/solid": ["@opentui/solid@0.1.32", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.32", "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-JYQ7DpC1oF1jCO4I27mxYGz8M5AmBzsm7xbcG3VANA9cpCY/Tp3YXxanw2Gx1G/xSTY6QMBPhaTEuiFdIQ6FRw=="],
"@opentui/solid": ["@opentui/solid@0.1.33", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.33", "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-bWSALdGJ2j51zwZ2gK1ZIBxFgauHq+V1ejEnyd4XamYMdWfpAKU+AUWDVLbpx1T9XG1oAnycJZfYX7BsZdVOOg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+3
View File
@@ -152,6 +152,9 @@ try {
return session.id.slice(-8)
})()
console.log("opencode session", session.id)
if (shareId) {
console.log("Share link:", `${useShareUrl()}/s/${shareId}`)
}
// Handle 3 cases
// 1. Issue
+11 -3
View File
@@ -61,7 +61,13 @@ export const auth = new sst.cloudflare.Worker("AuthApi", {
domain: `auth.${domain}`,
handler: "packages/console/function/src/auth.ts",
url: true,
link: [database, authStorage, GITHUB_CLIENT_ID_CONSOLE, GITHUB_CLIENT_SECRET_CONSOLE, GOOGLE_CLIENT_ID],
link: [
database,
authStorage,
GITHUB_CLIENT_ID_CONSOLE,
GITHUB_CLIENT_SECRET_CONSOLE,
GOOGLE_CLIENT_ID,
],
})
////////////////
@@ -97,7 +103,8 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
],
})
const ZEN_MODELS = new sst.Secret("ZEN_MODELS")
const ZEN_MODELS1 = new sst.Secret("ZEN_MODELS1")
const ZEN_MODELS2 = new sst.Secret("ZEN_MODELS2")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
@@ -130,7 +137,8 @@ new sst.cloudflare.x.SolidStart("Console", {
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
STRIPE_SECRET_KEY,
ZEN_MODELS,
ZEN_MODELS1,
ZEN_MODELS2,
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
+1 -1
View File
@@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "1.0.14"
"version": "1.0.19"
},
"dependencies": {
"@ibm/plex": "6.4.1",
@@ -1,6 +1,7 @@
import { createAsync } from "@solidjs/router"
import { createMemo } from "solid-js"
import { github } from "~/lib/github"
import { config } from "~/config"
export function Footer() {
const githubData = createAsync(() => github())
@@ -10,13 +11,13 @@ export function Footer() {
notation: "compact",
compactDisplay: "short",
}).format(githubData()!.stars!)
: "25K",
: config.github.starsFormatted.compact,
)
return (
<footer data-component="footer">
<div data-slot="cell">
<a href="https://github.com/sst/opencode" target="_blank">
<a href={config.github.repoUrl} target="_blank">
GitHub <span>[{starCount()}]</span>
</a>
</div>
@@ -27,7 +28,7 @@ export function Footer() {
<a href="/discord">Discord</a>
</div>
<div data-slot="cell">
<a href="https://x.com/opencode">X</a>
<a href={config.social.twitter}>X</a>
</div>
</footer>
)
@@ -18,6 +18,7 @@ import { createMemo, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { github } from "~/lib/github"
import { createEffect, onCleanup } from "solid-js"
import { config } from "~/config"
import "./header-context-menu.css"
const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches
@@ -42,7 +43,7 @@ export function Header(props: { zen?: boolean }) {
notation: "compact",
compactDisplay: "short",
}).format(githubData()?.stars!)
: "29K",
: config.github.starsFormatted.compact,
)
const [store, setStore] = createStore({
@@ -148,7 +149,7 @@ export function Header(props: { zen?: boolean }) {
<nav data-component="nav-desktop">
<ul>
<li>
<a href="https://github.com/sst/opencode" target="_blank">
<a href={config.github.repoUrl} target="_blank">
GitHub <span>[{starCount()}]</span>
</a>
</li>
@@ -222,7 +223,7 @@ export function Header(props: { zen?: boolean }) {
<A href="/">Home</A>
</li>
<li>
<a href="https://github.com/sst/opencode" target="_blank">
<a href={config.github.repoUrl} target="_blank">
GitHub <span>[{starCount()}]</span>
</a>
</li>
+26
View File
@@ -0,0 +1,26 @@
/**
* Application-wide constants and configuration
*/
export const config = {
// GitHub
github: {
repoUrl: "https://github.com/sst/opencode",
starsFormatted: {
compact: "30K",
full: "30,000",
},
},
// Social links
social: {
twitter: "https://x.com/opencode",
discord: "https://discord.gg/opencode",
},
// Static stats (used on landing page)
stats: {
contributors: "250",
commits: "3,500",
monthlyUsers: "300,000",
},
} as const
+8 -3
View File
@@ -1,4 +1,5 @@
import { query } from "@solidjs/router"
import { config } from "~/config"
export const github = query(async () => {
"use server"
@@ -6,11 +7,15 @@ export const github = query(async () => {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
}
const apiBaseUrl = config.github.repoUrl.replace(
"https://github.com/",
"https://api.github.com/repos/",
)
try {
const [meta, releases, contributors] = await Promise.all([
fetch("https://api.github.com/repos/sst/opencode", { headers }).then((res) => res.json()),
fetch("https://api.github.com/repos/sst/opencode/releases", { headers }).then((res) => res.json()),
fetch("https://api.github.com/repos/sst/opencode/contributors?per_page=1", { headers }),
fetch(apiBaseUrl, { headers }).then((res) => res.json()),
fetch(`${apiBaseUrl}/releases`, { headers }).then((res) => res.json()),
fetch(`${apiBaseUrl}/contributors?per_page=1`, { headers }),
])
const [release] = releases
const contributorCount = Number.parseInt(
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,15 @@ import { withActor } from "~/context/auth.withActor"
import { ZenData } from "@opencode-ai/console-core/model.js"
import styles from "./model-section.module.css"
import { querySessionInfo } from "../common"
import { IconAlibaba, IconAnthropic, IconMoonshotAI, IconOpenAI, IconStealth, IconXai, IconZai } from "~/component/icon"
import {
IconAlibaba,
IconAnthropic,
IconMoonshotAI,
IconOpenAI,
IconStealth,
IconXai,
IconZai,
} from "~/component/icon"
const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic"
@@ -22,8 +30,7 @@ const getModelsInfo = query(async (workspaceID: string) => {
return withActor(async () => {
return {
all: Object.entries(ZenData.list().models)
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
.filter(([id, _model]) => !id.startsWith("an-"))
.filter(([id, _model]) => !["claude-3-5-haiku", "minimax-m2"].includes(id))
.sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name))
.map(([id, model]) => ({ id, name: model.name })),
disabled: await Model.listDisabled(),
@@ -68,7 +75,8 @@ export function ModelSection() {
<div data-slot="section-title">
<h2>Models</h2>
<p>
Manage which models workspace members can access. <a href="/docs/zen#pricing ">Learn more</a>.
Manage which models workspace members can access.{" "}
<a href="/docs/zen#pricing ">Learn more</a>.
</p>
</div>
<div data-slot="models-list">
@@ -1 +0,0 @@
export type Format = "anthropic" | "openai" | "oa-compat"
@@ -18,7 +18,6 @@ import {
createStreamPartConverter,
createResponseConverter,
} from "./provider/provider"
import { Format } from "./format"
import { anthropicHelper } from "./provider/anthropic"
import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible"
@@ -29,7 +28,7 @@ type Model = ZenData["models"][string]
export async function handler(
input: APIEvent,
opts: {
format: Format
format: ZenData.Format
parseApiKey: (headers: Headers) => string | undefined
},
) {
@@ -239,21 +238,23 @@ export async function handler(
.filter((provider) => !provider.disabled)
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
// Use last character of IP address to select a provider
const lastChar = ip.charCodeAt(ip.length - 1) || 0
const index = lastChar % providers.length
const provider = providers[index]
// Use the last 2 characters of IP address to select a provider
const lastChars = ip.slice(-2)
const index = parseInt(lastChars, 16) % providers.length
const provider = providers[index || 0]
if (!(provider.id in zenData.providers)) {
throw new ModelError(`Provider ${provider.id} not supported`)
}
const format = zenData.providers[provider.id].format
return {
...provider,
...zenData.providers[provider.id],
...(provider.id === "anthropic"
...(format === "anthropic"
? anthropicHelper
: provider.id === "openai"
: format === "openai"
? openaiHelper
: oaCompatHelper),
}
@@ -1,5 +1,4 @@
import { Format } from "../format"
import { ZenData } from "@opencode-ai/console-core/model.js"
import {
fromAnthropicChunk,
fromAnthropicRequest,
@@ -26,7 +25,7 @@ import {
} from "./openai-compatible"
export type ProviderHelper = {
format: Format
format: ZenData.Format
modifyUrl: (providerApi: string) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
modifyBody: (body: Record<string, any>) => Record<string, any>
@@ -158,7 +157,7 @@ export interface CommonChunk {
}
}
export function createBodyConverter(from: Format, to: Format) {
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
return (body: any): any => {
if (from === to) return body
@@ -173,7 +172,7 @@ export function createBodyConverter(from: Format, to: Format) {
}
}
export function createStreamPartConverter(from: Format, to: Format) {
export function createStreamPartConverter(from: ZenData.Format, to: ZenData.Format) {
return (part: any): any => {
if (from === to) return part
@@ -191,7 +190,7 @@ export function createStreamPartConverter(from: Format, to: Format) {
}
}
export function createResponseConverter(from: Format, to: Format) {
export function createResponseConverter(from: ZenData.Format, to: ZenData.Format) {
return (response: any): any => {
if (from === to) return response
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.14",
"version": "1.0.19",
"private": true,
"type": "module",
"dependencies": {
+11 -5
View File
@@ -11,14 +11,20 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const value = ret
const value1 = ret
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS"))
.find((line) => line.startsWith("ZEN_MODELS1"))
?.split("=")[1]
if (!value) throw new Error("ZEN_MODELS not found")
const value2 = ret
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS2"))
?.split("=")[1]
if (!value1) throw new Error("ZEN_MODELS1 not found")
if (!value2) throw new Error("ZEN_MODELS2 not found")
// validate value
ZenData.validate(JSON.parse(value))
ZenData.validate(JSON.parse(value1 + value2))
// update the secret
await $`bun sst secret set ZEN_MODELS ${value} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS2 ${value2} --stage ${stage}`
+14 -8
View File
@@ -10,23 +10,29 @@ const models = await $`bun sst secret list`.cwd(root).text()
console.log("models", models)
// read the line starting with "ZEN_MODELS"
const oldValue = models
const oldValue1 = models
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS"))
.find((line) => line.startsWith("ZEN_MODELS1"))
?.split("=")[1]
if (!oldValue) throw new Error("ZEN_MODELS not found")
console.log("oldValue", oldValue)
const oldValue2 = models
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS2"))
?.split("=")[1]
if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
if (!oldValue2) throw new Error("ZEN_MODELS2 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(oldValue), null, 2))
await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2), null, 2))
console.log("tempFile", tempFile.name)
// open temp file in vim and read the file on close
await $`vim ${tempFile.name}`
const newValue = JSON.parse(await tempFile.text())
ZenData.validate(newValue)
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
ZenData.validate(JSON.parse(newValue))
// update the secret
await $`bun sst secret set ZEN_MODELS ${JSON.stringify(newValue)}`
const mid = Math.floor(newValue.length / 2)
await $`bun sst secret set ZEN_MODELS1 ${newValue.slice(0, mid)}`
await $`bun sst secret set ZEN_MODELS2 ${newValue.slice(mid)}`
+8 -2
View File
@@ -8,6 +8,9 @@ import { Actor } from "./actor"
import { Resource } from "@opencode-ai/console-resource"
export namespace ZenData {
const FormatSchema = z.enum(["anthropic", "openai", "oa-compat"])
export type Format = z.infer<typeof FormatSchema>
const ModelCostSchema = z.object({
input: z.number(),
output: z.number(),
@@ -34,6 +37,7 @@ export namespace ZenData {
const ProviderSchema = z.object({
api: z.string(),
apiKey: z.string(),
format: FormatSchema,
headerMappings: z.record(z.string(), z.string()).optional(),
})
@@ -47,7 +51,7 @@ export namespace ZenData {
})
export const list = fn(z.void(), () => {
const json = JSON.parse(Resource.ZEN_MODELS.value)
const json = JSON.parse(Resource.ZEN_MODELS1.value + Resource.ZEN_MODELS2.value)
return ModelsSchema.parse(json)
})
}
@@ -56,7 +60,9 @@ export namespace Model {
export const enable = fn(z.object({ model: z.string() }), ({ model }) => {
Actor.assertAdmin()
return Database.use((db) =>
db.delete(ModelTable).where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))),
db
.delete(ModelTable)
.where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))),
)
})
+96 -1
View File
@@ -3,7 +3,102 @@
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../../sst-env.d.ts" />
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
}
"AWS_SES_ACCESS_KEY_ID": {
"type": "sst.sst.Secret"
"value": string
}
"AWS_SES_SECRET_ACCESS_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"Console": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"Database": {
"database": string
"host": string
"password": string
"port": number
"type": "sst.sst.Linkable"
"username": string
}
"Desktop": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_PRIVATE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_ID_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_SECRET_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GOOGLE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_WEBHOOK_SECRET": {
"type": "sst.sst.Linkable"
"value": string
}
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"LogProcessor": cloudflare.Service
}
}
import "sst"
export {}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.14",
"version": "1.0.19",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+9 -1
View File
@@ -6,6 +6,10 @@
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
@@ -74,7 +78,11 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"ZEN_MODELS": {
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.14",
"version": "1.0.19",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+9 -1
View File
@@ -6,6 +6,10 @@
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
@@ -74,7 +78,11 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"ZEN_MODELS": {
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.14",
"version": "1.0.19",
"description": "",
"type": "module",
"scripts": {
+4 -4
View File
@@ -90,7 +90,7 @@ export function Code(props: Props) {
let ticking = false
const onScroll = () => {
if (!container) return
if (ctx.file.active()?.path !== local.path) return
// if (ctx.file.active()?.path !== local.path) return
if (ticking) return
ticking = true
requestAnimationFrame(() => {
@@ -102,7 +102,7 @@ export function Code(props: Props) {
const onSelectionChange = () => {
if (!container) return
if (isProgrammaticSelection) return
if (ctx.file.active()?.path !== local.path) return
// if (ctx.file.active()?.path !== local.path) return
const d = getSelectionInContainer(container)
if (!d) return
const p = ctx.file.node(local.path)?.selection
@@ -112,7 +112,7 @@ export function Code(props: Props) {
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
const onKeyDown = (e: KeyboardEvent) => {
if (ctx.file.active()?.path !== local.path) return
// if (ctx.file.active()?.path !== local.path) return
const ae = document.activeElement as HTMLElement | undefined
const tag = (ae?.tagName || "").toLowerCase()
const inputFocused = !!ae && (tag === "input" || tag === "textarea" || ae.isContentEditable)
@@ -155,7 +155,7 @@ export function Code(props: Props) {
createEffect(() => {
const content = html()
if (!container || !content) return
if (ctx.file.active()?.path !== local.path) return
// if (ctx.file.active()?.path !== local.path) return
const codeEl = container.querySelector("code") as HTMLElement | undefined
if (!codeEl) return
const target = ctx.file.node(local.path)?.selection
@@ -19,7 +19,7 @@ export default function FileTree(props: {
component={p.as ?? "div"}
classList={{
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
"bg-background-element": local.file.active()?.path === p.node.path,
// "bg-background-element": local.file.active()?.path === p.node.path,
[props.nodeClass ?? ""]: !!props.nodeClass,
}}
style={`padding-left: ${level * 10}px`}
@@ -55,7 +55,7 @@ export default function FileTree(props: {
"text-xs whitespace-nowrap truncate": true,
"text-text-muted/40": p.node.ignored,
"text-text-muted/80": !p.node.ignored,
"!text-text": local.file.active()?.path === p.node.path,
// "!text-text": local.file.active()?.path === p.node.path,
"!text-primary": local.file.changed(p.node.path),
}}
>
@@ -1,7 +1,7 @@
import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { Markdown, Part } from "@opencode-ai/ui"
import { Part } from "@opencode-ai/ui"
import { useSync } from "@/context/sync"
import type { AssistantMessage as AssistantMessageType, Part as PartType, ToolPart } from "@opencode-ai/sdk"
import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk"
import { Spinner } from "./spinner"
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
@@ -22,7 +22,6 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
p.state.status === "running",
) as ToolPart,
)
const resolvedParts = createMemo(() => {
let resolved = parts()
const task = currentTask()
@@ -32,20 +31,18 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
}
return resolved
})
const currentText = createMemo(
() =>
resolvedParts().findLast((p) => p?.type === "text")?.text ||
resolvedParts().findLast((p) => p?.type === "reasoning")?.text,
)
// const currentText = createMemo(
// () =>
// resolvedParts().findLast((p) => p?.type === "text")?.text ||
// resolvedParts().findLast((p) => p?.type === "reasoning")?.text,
// )
const eligibleItems = createMemo(() => {
return resolvedParts().filter((p) => p?.type === "tool" && p.state.status === "completed")
return resolvedParts().filter((p) => p?.type === "tool" && p?.state.status === "completed") as ToolPart[]
})
const finishedItems = createMemo<(JSXElement | PartType)[]>(() => [
const finishedItems = createMemo<(JSXElement | ToolPart)[]>(() => [
<div class="h-8 w-full" />,
<div class="h-8 w-full" />,
<div class="h-8 w-full" />,
<div class="flex items-center gap-x-5 pl-3 text-text-base">
<Spinner /> <span class="text-12-medium">Thinking...</span>
</div>,
...eligibleItems(),
...(done() ? [<div class="h-8 w-full" />, <div class="h-8 w-full" />, <div class="h-8 w-full" />] : []),
])
@@ -71,57 +68,120 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
return `-${(total - 2) * 40 - 8}px`
})
const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
const rawStatus = createMemo(() => {
const defaultStatus = "Working..."
const last = lastPart()
if (!last) return defaultStatus
if (last.type === "tool") {
switch (last.tool) {
case "task":
return "Delegating work..."
case "todowrite":
case "todoread":
return "Planning next steps..."
case "read":
return "Gathering context..."
case "list":
case "grep":
case "glob":
return "Searching the codebase..."
case "webfetch":
return "Searching the web..."
case "edit":
case "write":
return "Making edits..."
case "bash":
return "Running commands..."
default:
break
}
} else if (last.type === "reasoning") {
return "Thinking..."
} else if (last.type === "text") {
return "Gathering thoughts..."
}
return defaultStatus
})
const [status, setStatus] = createSignal(rawStatus())
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === status()) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 1000) {
setStatus(newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStatus(rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 1000 - timeSinceLastChange) as unknown as number
}
})
return (
<div class="flex flex-col gap-3">
<div
class="h-30 overflow-hidden pointer-events-none pb-1
{/* <Show when={currentText()}> */}
{/* {(text) => ( */}
{/* <div */}
{/* class="h-20 flex flex-col justify-end overflow-hidden py-3 */}
{/* mask-alpha mask-t-from-80% mask-t-from-background-base mask-t-to-transparent" */}
{/* > */}
{/* <Markdown text={text()} class="w-full shrink-0 overflow-visible" /> */}
{/* </div> */}
{/* )} */}
{/* </Show> */}
<div class="flex items-center gap-x-5 pl-3 border border-transparent text-text-base">
<Spinner /> <span class="text-12-medium">{status()}</span>
</div>
<Show when={eligibleItems().length > 0}>
<div
class="h-30 overflow-hidden pointer-events-none pb-1
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
mask-b-from-95% mask-b-from-background-base mask-b-to-transparent"
>
<div
class="w-full flex flex-col items-start self-stretch gap-2 py-8
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(${translateY()})` }}
>
<For each={finishedItems()}>
{(part) => {
if (part && typeof part === "object" && "type" in part) {
const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
return (
<div class="h-8 flex items-center w-full">
<Switch>
<Match when={part.type === "text" && part}>
{(p) => (
<div
textContent={p().text}
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
/>
)}
</Match>
<Match when={part.type === "reasoning" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
<Match when={part.type === "tool" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
</Switch>
</div>
)
}
return <div class="h-8 flex items-center w-full">{part}</div>
}}
</For>
</div>
</div>
<Show when={currentText()}>
{(text) => (
<div
class="max-h-36 flex flex-col justify-end overflow-hidden py-3
mask-alpha mask-t-from-80% mask-t-from-background-base mask-t-to-transparent"
class="w-full flex flex-col items-start self-stretch gap-2 py-8
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(${translateY()})` }}
>
<Markdown text={text()} class="w-full shrink-0 overflow-visible" />
<For each={finishedItems()}>
{(part) => (
<Switch>
<Match when={part && typeof part === "object" && "type" in part && part}>
{(p) => {
const part = p() as ToolPart
const message = createMemo(() =>
sync.data.message[part.sessionID].find((m) => m.id === part.messageID),
)
return (
<div class="h-8 flex items-center w-full">
<Part message={message()!} part={part} />
</div>
)
}}
</Match>
<Match when={true}>
<div class="h-8 flex items-center w-full">{part as JSXElement}</div>
</Match>
</Switch>
)}
</For>
</div>
)}
</div>
</Show>
</div>
)
@@ -72,7 +72,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({
items: local.file.search,
items: local.file.searchFilesAndDirectories,
key: (x) => x,
onSelect: (path) => {
if (!path) return
@@ -307,27 +307,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<Show when={store.popoverIsOpen}>
<div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
<For each={flat()}>
{(i) => (
<div
classList={{
"w-full flex items-center justify-between rounded-md": true,
"bg-surface-raised-base-hover": active() === i,
}}
>
<div class="flex items-center gap-x-2 grow min-w-0">
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}>
<For each={flat()}>
{(i) => (
<div
classList={{
"w-full flex items-center justify-between rounded-md": true,
"bg-surface-raised-base-hover": active() === i,
}}
>
<div class="flex items-center gap-x-2 grow min-w-0">
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</div>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</div>
)}
</For>
)}
</For>
</Show>
</div>
</Show>
<form
+114 -172
View File
@@ -1,16 +1,7 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js"
import { pipe, sumBy, uniqueBy } from "remeda"
import type {
FileContent,
FileNode,
Model,
Provider,
File as FileStatus,
Part,
Message,
AssistantMessage,
} from "@opencode-ai/sdk"
import { uniqueBy } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
@@ -204,18 +195,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const file = (() => {
const [store, setStore] = createStore<{
node: Record<string, LocalFile>
opened: string[]
active?: string
// opened: string[]
// active?: string
}>({
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
opened: [],
// opened: [],
})
const active = createMemo(() => {
if (!store.active) return undefined
return store.node[store.active]
})
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
// const active = createMemo(() => {
// if (!store.active) return undefined
// return store.node[store.active]
// })
// const opened = createMemo(() => store.opened.map((x) => store.node[x]))
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
@@ -303,16 +294,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
const relativePath = relative(path)
if (!store.node[relativePath]) await fetch(path)
setStore("opened", (x) => {
if (x.includes(relativePath)) return x
return [
...opened()
.filter((x) => x.pinned)
.map((x) => x.path),
relativePath,
]
})
setStore("active", relativePath)
// setStore("opened", (x) => {
// if (x.includes(relativePath)) return x
// return [
// ...opened()
// .filter((x) => x.pinned)
// .map((x) => x.path),
// relativePath,
// ]
// })
// setStore("active", relativePath)
context.addActive()
if (options?.pinned) setStore("node", path, "pinned", true)
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
@@ -334,7 +325,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
}
const search = (query: string) => sdk.client.find.files({ query: { query } }).then((x) => x.data!)
const searchFiles = (query: string) =>
sdk.client.find.files({ query: { query, dirs: "false" } }).then((x) => x.data!)
const searchFilesAndDirectories = (query: string) =>
sdk.client.find.files({ query: { query, dirs: "true" } }).then((x) => x.data!)
sdk.event.listen((e) => {
const event = e.details
@@ -363,22 +357,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
return {
active,
opened,
node: (path: string) => store.node[path],
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
open,
load,
init,
close(path: string) {
setStore("opened", (opened) => opened.filter((x) => x !== path))
if (store.active === path) {
const index = store.opened.findIndex((f) => f === path)
const previous = store.opened[Math.max(0, index - 1)]
setStore("active", previous)
}
resetNode(path)
},
expand(path: string) {
setStore("node", path, "expanded", true)
if (store.node[path].loaded) return
@@ -394,17 +377,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
scroll(path: string, scrollTop: number) {
setStore("node", path, "scrollTop", scrollTop)
},
move(path: string, to: number) {
const index = store.opened.findIndex((f) => f === path)
if (index === -1) return
setStore(
"opened",
produce((opened) => {
opened.splice(to, 0, opened.splice(index, 1)[0])
}),
)
setStore("node", path, "pinned", true)
},
view(path: string): View {
const n = store.node[path]
return n && n.view ? n.view : "raw"
@@ -442,16 +414,51 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
!x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
)
},
search,
searchFiles,
searchFilesAndDirectories,
relative,
// active,
// opened,
// close(path: string) {
// setStore("opened", (opened) => opened.filter((x) => x !== path))
// if (store.active === path) {
// const index = store.opened.findIndex((f) => f === path)
// const previous = store.opened[Math.max(0, index - 1)]
// setStore("active", previous)
// }
// resetNode(path)
// },
// move(path: string, to: number) {
// const index = store.opened.findIndex((f) => f === path)
// if (index === -1) return
// setStore(
// "opened",
// produce((opened) => {
// opened.splice(to, 0, opened.splice(index, 1)[0])
// }),
// )
// setStore("node", path, "pinned", true)
// },
}
})()
const session = (() => {
const [store, setStore] = createStore<{
active?: string
activeMessage?: string
}>({})
tabs: Record<
string,
{
active?: string
opened: string[]
}
>
}>({
tabs: {
"": {
opened: [],
},
},
})
const active = createMemo(() => {
if (!store.active) return undefined
@@ -461,134 +468,69 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
createEffect(() => {
if (!store.active) return
sync.session.sync(store.active)
})
const valid = (part: Part) => {
if (!part) return false
switch (part.type) {
case "step-start":
case "step-finish":
case "file":
case "patch":
return false
case "text":
return !part.synthetic && part.text.trim()
case "reasoning":
return part.text.trim()
case "tool":
switch (part.tool) {
case "todoread":
case "todowrite":
case "list":
case "grep":
return false
}
return true
default:
return true
if (!store.tabs[store.active]) {
setStore("tabs", store.active, {
opened: [],
})
}
}
const hasValidParts = (message: Message) => {
return sync.data.part[message.id]?.filter(valid).length > 0
}
// const hasTextPart = (message: Message) => {
// return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
// }
const messages = createMemo(() => (store.active ? (sync.data.message[store.active] ?? []) : []))
const messagesWithValidParts = createMemo(() => messages().filter(hasValidParts) ?? [])
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
.sort((a, b) => b.id.localeCompare(a.id)),
)
const cost = createMemo(() => {
const total = pipe(
messages(),
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const last = createMemo(() => {
return messages().findLast((x) => x.role === "assistant") as AssistantMessage
})
const lastUserMessage = createMemo(() => {
return userMessages()?.at(0)
})
const activeMessage = createMemo(() => {
if (!store.active || !store.activeMessage) return lastUserMessage()
return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage)
})
const model = createMemo(() => {
if (!last()) return
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
return model
})
const tokens = createMemo(() => {
if (!last()) return
const tokens = last().tokens
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
return new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(total)
})
const context = createMemo(() => {
if (!last()) return
if (!model()?.limit.context) return 0
const tokens = last().tokens
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
return Math.round((total / model()!.limit.context) * 100)
})
const getMessageText = (message: Message | Message[] | undefined): string => {
if (!message) return ""
if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ")
return sync.data.part[message.id]
?.filter((p) => p.type === "text")
?.filter((p) => !p.synthetic)
.map((p) => p.text)
.join(" ")
}
const tabs = createMemo(() => store.tabs[store.active ?? ""])
return {
active,
activeMessage,
lastUserMessage,
cost,
last,
model,
tokens,
context,
messages,
messagesWithValidParts,
userMessages,
// working,
getMessageText,
setActive(sessionId: string | undefined) {
setStore("active", sessionId)
setStore("activeMessage", undefined)
},
clearActive() {
setStore("active", undefined)
setStore("activeMessage", undefined)
},
setActiveMessage(messageId: string | undefined) {
setStore("activeMessage", messageId)
tabs,
copyTabs(from: string, to: string) {
setStore("tabs", to, {
opened: store.tabs[from]?.opened ?? [],
})
},
clearActiveMessage() {
setStore("activeMessage", undefined)
setActiveTab(tab: string | undefined) {
setStore("tabs", store.active ?? "", "active", tab)
},
async open(tab: string) {
if (tab !== "chat") {
await file.open(tab)
}
if (!tabs()?.opened?.includes(tab)) {
setStore("tabs", store.active ?? "", "opened", [...(tabs()?.opened ?? []), tab])
}
setStore("tabs", store.active ?? "", "active", tab)
},
close(tab: string) {
batch(() => {
if (!tabs()) return
setStore("tabs", store.active ?? "", {
active: tabs()!.active,
opened: tabs()!.opened.filter((x) => x !== tab),
})
if (tabs()!.active === tab) {
const index = tabs()!.opened.findIndex((f) => f === tab)
const previous = tabs()!.opened[Math.max(0, index - 1)]
setStore("tabs", store.active ?? "", "active", previous)
}
})
},
move(tab: string, to: number) {
if (!tabs()) return
const index = tabs()!.opened.findIndex((f) => f === tab)
if (index === -1) return
setStore(
"tabs",
store.active ?? "",
"opened",
produce((opened) => {
opened.splice(to, 0, opened.splice(index, 1)[0])
}),
)
// setStore("node", path, "pinned", true)
},
}
})()
@@ -611,9 +553,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
all() {
return store.items
},
active() {
return store.activeTab ? file.active() : undefined
},
// active() {
// return store.activeTab ? file.active() : undefined
// },
addActive() {
setStore("activeTab", true)
},
+325 -396
View File
@@ -10,9 +10,10 @@ import {
Diff,
Collapsible,
DiffChanges,
ProgressCircle,
Message,
Typewriter,
Card,
Code,
} from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree"
@@ -34,7 +35,6 @@ import {
} from "@thisbeyond/solid-dnd"
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { JSX } from "solid-js"
import { Code } from "@/components/code"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
@@ -53,14 +53,6 @@ export default function Page() {
let messageScrollElement!: HTMLDivElement
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
createEffect(() => {
// Set first message as active if none selected
const userMessages = local.session.userMessages()
if (userMessages.length > 0 && !local.session.activeMessage()) {
local.session.setActiveMessage(userMessages[0].id)
}
})
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
onMount(() => {
@@ -90,26 +82,26 @@ export default function Page() {
return
}
if (local.file.active()) {
const active = local.file.active()!
if (event.key === "Enter" && active.selection) {
local.context.add({
type: "file",
path: active.path,
selection: { ...active.selection },
})
return
}
if (event.getModifierState(MOD)) {
if (event.key.toLowerCase() === "a") {
return
}
if (event.key.toLowerCase() === "c") {
return
}
}
}
// if (local.file.active()) {
// const active = local.file.active()!
// if (event.key === "Enter" && active.selection) {
// local.context.add({
// type: "file",
// path: active.path,
// selection: { ...active.selection },
// })
// return
// }
//
// if (event.getModifierState(MOD)) {
// if (event.key.toLowerCase() === "a") {
// return
// }
// if (event.key.toLowerCase() === "c") {
// return
// }
// }
// }
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
inputRef?.focus()
@@ -139,21 +131,22 @@ export default function Page() {
}
}
const navigateChange = (dir: 1 | -1) => {
const active = local.file.active()
if (!active) return
const current = local.file.changeIndex(active.path)
const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
local.file.setChangeIndex(active.path, next)
}
// const navigateChange = (dir: 1 | -1) => {
// const active = local.file.active()
// if (!active) return
// const current = local.file.changeIndex(active.path)
// const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
// local.file.setChangeIndex(active.path, next)
// }
const handleTabChange = (path: string) => {
if (path === "chat" || path === "review") return
local.file.open(path)
local.session.setActiveTab(path)
if (path === "chat") return
local.session.open(path)
}
const handleTabClose = (file: LocalFile) => {
local.file.close(file.path)
local.session.close(file.path)
}
const handleDragStart = (event: unknown) => {
@@ -165,11 +158,11 @@ export default function Page() {
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const currentFiles = local.file.opened().map((file) => file.path)
const fromIndex = currentFiles.indexOf(draggable.id.toString())
const toIndex = currentFiles.indexOf(droppable.id.toString())
if (fromIndex !== toIndex) {
local.file.move(draggable.id.toString(), toIndex)
const currentFiles = local.session.tabs()?.opened.map((file) => file)
const fromIndex = currentFiles?.indexOf(draggable.id.toString())
const toIndex = currentFiles?.indexOf(droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== undefined) {
local.session.move(draggable.id.toString(), toIndex)
}
}
}
@@ -178,20 +171,20 @@ export default function Page() {
setActiveItem(undefined)
}
const scrollDiffItem = (element: HTMLElement) => {
element.scrollIntoView({ block: "start", behavior: "instant" })
}
// const scrollDiffItem = (element: HTMLElement) => {
// element.scrollIntoView({ block: "start", behavior: "instant" })
// }
const handleDiffTriggerClick = (event: MouseEvent) => {
// disabling scroll to diff for now
return
const target = event.currentTarget as HTMLElement
queueMicrotask(() => {
if (target.getAttribute("aria-expanded") !== "true") return
const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
if (!item) return
scrollDiffItem(item)
})
// const target = event.currentTarget as HTMLElement
// queueMicrotask(() => {
// if (target.getAttribute("aria-expanded") !== "true") return
// const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
// if (!item) return
// scrollDiffItem(item)
// })
}
const handlePromptSubmit = async (parts: ContentPart[]) => {
@@ -204,6 +197,10 @@ export default function Page() {
if (!session) return
local.session.setActive(session.id)
if (!existingSession) {
local.session.copyTabs("", session.id)
}
local.session.setActiveTab(undefined)
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const text = parts.map((part) => part.content).join("")
@@ -426,20 +423,26 @@ export default function Page() {
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs onChange={handleTabChange}>
<Tabs value={local.session.tabs()?.active ?? "chat"} onChange={handleTabChange}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
<div>Chat</div>
<Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5">
<ProgressCircle percentage={local.session.context() ?? 0} />
<div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div>
</Tooltip>
{/* <Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5"> */}
{/* <ProgressCircle percentage={local.session.context() ?? 0} /> */}
{/* <div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div> */}
{/* </Tooltip> */}
</Tabs.Trigger>
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
<For each={local.file.opened()}>
{(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
<SortableProvider ids={local.session.tabs()?.opened ?? []}>
<For each={local.session.tabs()?.opened ?? []}>
{(file) => (
<SortableTab
file={local.file.node(file)}
onTabClick={handleFileClick}
onTabClose={handleTabClose}
/>
)}
</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
@@ -451,64 +454,6 @@ export default function Page() {
/>
</div>
</Tabs.List>
<div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40">
<Show when={local.file.active() && local.file.active()!.content?.diff}>
{(() => {
const activeFile = local.file.active()!
const view = local.file.view(activeFile.path)
return (
<div class="flex items-center gap-1">
<Show when={view !== "raw"}>
<div class="mr-1 flex items-center gap-1">
<Tooltip value="Previous change" placement="bottom">
<IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
</Tooltip>
<Tooltip value="Next change" placement="bottom">
<IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
</Tooltip>
</div>
</Show>
<Tooltip value="Raw" placement="bottom">
<IconButton
icon="file-text"
variant="ghost"
classList={{
"text-text": view === "raw",
"text-text-muted/70": view !== "raw",
"bg-background-element": view === "raw",
}}
onClick={() => local.file.setView(activeFile.path, "raw")}
/>
</Tooltip>
<Tooltip value="Unified diff" placement="bottom">
<IconButton
icon="checklist"
variant="ghost"
classList={{
"text-text": view === "diff-unified",
"text-text-muted/70": view !== "diff-unified",
"bg-background-element": view === "diff-unified",
}}
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
/>
</Tooltip>
<Tooltip value="Split diff" placement="bottom">
<IconButton
icon="columns"
variant="ghost"
classList={{
"text-text": view === "diff-split",
"text-text-muted/70": view !== "diff-split",
"bg-background-element": view === "diff-split",
}}
onClick={() => local.file.setView(activeFile.path, "diff-split")}
/>
</Tooltip>
</div>
)
})()}
</Show>
</div>
</div>
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
<div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
@@ -536,305 +481,289 @@ export default function Page() {
</div>
}
>
{(activeSession) => (
<div class="pt-3 flex flex-col flex-1 min-h-0">
<div class="flex-1 min-h-0">
<Show when={local.session.userMessages().length > 1}>
<ul
role="list"
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
>
<For each={local.session.userMessages()}>
{(session) => {
const [store, setStore] = createStore<{
messageId?: string
}>()
const messages = createMemo(() => sync.data.message[session().id] ?? [])
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
.sort((a, b) => b.id.localeCompare(a.id)),
)
const lastUserMessage = createMemo(() => {
return userMessages()?.at(0)
})
const activeMessage = createMemo(() => {
if (!store.messageId) return lastUserMessage()
return userMessages()?.find((m) => m.id === store.messageId)
})
return (
<div class="pt-3 flex flex-col flex-1 min-h-0">
<div class="flex-1 min-h-0">
<Show when={userMessages().length > 1}>
<ul
role="list"
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
>
<For each={userMessages()}>
{(message) => {
const diffs = createMemo(() => message.summary?.diffs ?? [])
const assistantMessages = createMemo(() => {
return sync.data.message[session().id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const working = createMemo(() => !message.summary?.body && !error())
return (
<li class="group/li flex items-center self-stretch">
<button
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
onClick={() => setStore("messageId", message.id)}
>
<Switch>
<Match when={working()}>
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
</Match>
<Match when={true}>
<DiffChanges diff={diffs()} variant="bars" />
</Match>
</Switch>
<div
data-active={activeMessage()?.id === message.id}
classList={{
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
>
<Show when={message.summary?.title} fallback="New message">
{message.summary?.title}
</Show>
</div>
</button>
</li>
)
}}
</For>
</ul>
</Show>
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
<For each={userMessages()}>
{(message) => {
const diffs = createMemo(() => message.summary?.diffs ?? [])
const working = createMemo(() => !message.summary?.body)
const isActive = createMemo(() => activeMessage()?.id === message.id)
const [titled, setTitled] = createSignal(!!message.summary?.title)
const assistantMessages = createMemo(() => {
return sync.data.message[activeSession().id]?.filter(
return sync.data.message[session().id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const parts = createMemo(() =>
assistantMessages().flatMap((m) => sync.data.part[m.id]),
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
const [expanded, setExpanded] = createSignal(false)
const parts = createMemo(() => sync.data.part[message.id])
const title = createMemo(() => message.summary?.title)
const summary = createMemo(() => message.summary?.body)
const diffs = createMemo(() => message.summary?.diffs ?? [])
const hasToolPart = createMemo(() =>
assistantMessages()
?.flatMap((m) => sync.data.part[m.id])
.some((p) => p?.type === "tool"),
)
const lastPart = createMemo(() => parts().slice(-1)?.at(0))
const rawStatus = createMemo(() => {
const defaultStatus = "Working..."
const last = lastPart()
if (!last) return defaultStatus
if (last.type === "tool") {
switch (last.tool) {
case "task":
return "Delegating work..."
case "todowrite":
case "todoread":
return "Planning next steps..."
case "read":
return "Gathering context..."
case "list":
case "grep":
case "glob":
return "Searching the codebase..."
case "webfetch":
return "Searching the web..."
case "edit":
case "write":
return "Making edits..."
case "bash":
return "Running commands..."
default:
break
}
} else if (last.type === "reasoning") {
return "Thinking..."
} else if (last.type === "text") {
return "Gathering thoughts..."
}
return defaultStatus
})
const [status, setStatus] = createSignal(rawStatus())
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
const working = createMemo(() => !summary() && !error())
// allowing time for the animations to finish
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === status()) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 1000) {
setStatus(newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStatus(rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 1000 - timeSinceLastChange) as unknown as number
}
title()
setTimeout(() => setTitled(!!title()), 10_000)
})
createEffect(() => {
const complete = !!summary() || !!error()
setTimeout(() => setCompleted(complete), 1200)
})
return (
<li class="group/li flex items-center self-stretch">
<button
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
onClick={() => local.session.setActiveMessage(message.id)}
<Show when={isActive()}>
<div
data-message={message.id}
class="flex flex-col items-start self-stretch gap-8 pb-50"
>
<Switch>
<Match when={working()}>
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
</Match>
<Match when={true}>
<DiffChanges diff={diffs()} variant="bars" />
</Match>
</Switch>
<div
data-active={local.session.activeMessage()?.id === message.id}
classList={{
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
>
{/* Title */}
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
<div class="w-full text-14-medium text-text-strong">
<Show
when={titled()}
fallback={
<Typewriter
as="h1"
text={title()}
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
/>
}
>
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
{title()}
</h1>
</Show>
</div>
</div>
<div class="-mt-8">
<Message message={message} parts={parts()} />
</div>
{/* Summary */}
<Show when={completed()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch">
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">
<Switch>
<Match when={diffs().length}>Summary</Match>
<Match when={true}>Response</Match>
</Switch>
</h2>
<Show when={summary()}>
{(summary) => (
<Markdown
classList={{ "[&>*]:fade-up-text": !diffs().length }}
text={summary()}
/>
)}
</Show>
</div>
<Accordion class="w-full" multiple>
<For each={diffs()}>
{(diff) => (
<Accordion.Item value={diff.file}>
<Accordion.Header>
<Accordion.Trigger onClick={handleDiffTriggerClick}>
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
<FileIcon
node={{ path: diff.file, type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex grow min-w-0">
<Show when={diff.file.includes("/")}>
<span class="text-text-base truncate-start">
{getDirectory(diff.file)}&lrm;
</span>
</Show>
<span class="text-text-strong shrink-0">
{getFilename(diff.file)}
</span>
</div>
</div>
<div class="shrink-0 flex gap-4 items-center justify-end">
<DiffChanges diff={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<Diff
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
</Show>
<Show when={error() && !expanded()}>
<Card variant="error" class="text-text-on-critical-base">
{error()?.data?.message as string}
</Card>
</Show>
{/* Response */}
<div class="w-full">
<Switch>
<Match when={working()}>{status()}</Match>
<Match when={true}>{message.summary?.title}</Match>
<Match when={!completed()}>
<MessageProgress
assistantMessages={assistantMessages}
done={!working()}
/>
</Match>
<Match when={completed() && hasToolPart()}>
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
<div class="flex items-center gap-1 self-stretch">
<div class="text-12-medium">
<Switch>
<Match when={expanded()}>Hide details</Match>
<Match when={!expanded()}>Show details</Match>
</Switch>
</div>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="w-full flex flex-col items-start self-stretch gap-3">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(
() => sync.data.part[assistantMessage.id],
)
return <Message message={assistantMessage} parts={parts()} />
}}
</For>
<Show when={error()}>
<Card variant="error" class="text-text-on-critical-base">
{error()?.data?.message as string}
</Card>
</Show>
</div>
</Collapsible.Content>
</Collapsible>
</Match>
</Switch>
</div>
</button>
</li>
</div>
</Show>
)
}}
</For>
</ul>
</Show>
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
<For each={local.session.userMessages()}>
{(message) => {
const isActive = createMemo(() => local.session.activeMessage()?.id === message.id)
const [titled, setTitled] = createSignal(!!message.summary?.title)
const [completed, setCompleted] = createSignal(!!message.summary?.body)
const [expanded, setExpanded] = createSignal(false)
const parts = createMemo(() => sync.data.part[message.id])
const title = createMemo(() => message.summary?.title)
const summary = createMemo(() => message.summary?.body)
const diffs = createMemo(() => message.summary?.diffs ?? [])
const assistantMessages = createMemo(() => {
return sync.data.message[activeSession().id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const hasToolPart = createMemo(() =>
assistantMessages()
?.flatMap((m) => sync.data.part[m.id])
.some((p) => p?.type === "tool"),
)
const working = createMemo(() => !summary())
// allowing time for the animations to finish
createEffect(() => {
title()
setTimeout(() => setTitled(!!title()), 10_000)
})
createEffect(() => {
summary()
setTimeout(() => setCompleted(!!summary()), 1200)
})
return (
<Show when={isActive()}>
<div
data-message={message.id}
class="flex flex-col items-start self-stretch gap-8 pb-50"
>
{/* Title */}
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
<div class="w-full text-14-medium text-text-strong">
<Show
when={titled()}
fallback={
<Typewriter
as="h1"
text={title()}
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
/>
}
>
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">{title()}</h1>
</Show>
</div>
</div>
<div class="-mt-8">
<Message message={message} parts={parts()} />
</div>
{/* Summary */}
<Show when={completed()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch">
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">
<Switch>
<Match when={diffs().length}>Summary</Match>
<Match when={true}>Response</Match>
</Switch>
</h2>
<Show when={summary()}>
{(summary) => (
<Markdown
classList={{ "[&>*]:fade-up-text": !diffs().length }}
text={summary()}
/>
)}
</Show>
</div>
<Accordion class="w-full" multiple>
<For each={diffs()}>
{(diff) => (
<Accordion.Item value={diff.file}>
<Accordion.Header>
<Accordion.Trigger onClick={handleDiffTriggerClick}>
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
<FileIcon
node={{ path: diff.file, type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex grow min-w-0">
<Show when={diff.file.includes("/")}>
<span class="text-text-base truncate-start">
{getDirectory(diff.file)}&lrm;
</span>
</Show>
<span class="text-text-strong shrink-0">
{getFilename(diff.file)}
</span>
</div>
</div>
<div class="shrink-0 flex gap-4 items-center justify-end">
<DiffChanges diff={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<Diff
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
</Show>
{/* Response */}
<div class="w-full">
<Switch>
<Match when={!completed()}>
<MessageProgress assistantMessages={assistantMessages} done={!working()} />
</Match>
<Match when={completed() && hasToolPart()}>
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
<div class="flex items-center gap-1 self-stretch">
<div class="text-12-medium">
<Switch>
<Match when={expanded()}>Hide details</Match>
<Match when={!expanded()}>Show details</Match>
</Switch>
</div>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="w-full flex flex-col items-start self-stretch gap-3">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(
() => sync.data.part[assistantMessage.id],
)
return <Message message={assistantMessage} parts={parts()} />
}}
</For>
</div>
</Collapsible.Content>
</Collapsible>
</Match>
</Switch>
</div>
</div>
</Show>
)
}}
</For>
</div>
</div>
</div>
</div>
)}
)
}}
</Show>
</div>
</Tabs.Content>
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
<For each={local.file.opened()}>
<For each={local.session.tabs()?.opened}>
{(file) => (
<Tabs.Content value={file.path} class="select-text">
<Tabs.Content value={file} class="select-text">
{(() => {
const view = local.file.view(file.path)
const showRaw = view === "raw" || !file.content?.diff
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
{
/* const view = local.file.view(file) */
}
{
/* const showRaw = view === "raw" || !file.content?.diff */
}
{
/* const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") */
}
const node = local.file.node(file)
return (
<Code
file={{ name: node.path, contents: node.content?.content ?? "" }}
disableFileHeader
overflow="scroll"
class="pt-3 pb-40"
/>
)
})()}
</Tabs.Content>
)}
@@ -898,10 +827,10 @@ export default function Page() {
title="Select file"
placeholder="Search files"
emptyMessage="No files found"
items={local.file.search}
items={local.file.searchFiles}
key={(x) => x}
onOpenChange={(open) => setStore("fileSelectOpen", open)}
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
onSelect={(x) => (x ? local.session.open(x) : undefined)}
>
{(i) => (
<div
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.14",
"version": "1.0.19",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+9 -1
View File
@@ -6,6 +6,10 @@
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
@@ -74,7 +78,11 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"ZEN_MODELS": {
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.14",
"version": "1.0.19",
"name": "opencode",
"type": "module",
"private": true,
@@ -54,8 +54,8 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.32",
"@opentui/solid": "0.1.32",
"@opentui/core": "0.1.33",
"@opentui/solid": "0.1.33",
"@parcel/watcher": "2.5.1",
"@solid-primitives/event-bus": "1.1.2",
"@pierre/precision-diffs": "catalog:",
+9 -1
View File
@@ -5,6 +5,14 @@ export const GenerateCommand = {
command: "generate",
handler: async () => {
const specs = await Server.openapi()
process.stdout.write(JSON.stringify(specs, null, 2))
const json = JSON.stringify(specs, null, 2)
// Wait for stdout to finish writing before process.exit() is called
await new Promise<void>((resolve, reject) => {
process.stdout.write(json, (err) => {
if (err) reject(err)
else resolve()
})
})
},
} satisfies CommandModule
+196 -9
View File
@@ -1,4 +1,10 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "../../session"
import { bootstrap } from "../bootstrap"
import { Storage } from "../../storage/storage"
import { Project } from "../../project/project"
import { Instance } from "../../project/instance"
interface SessionStats {
totalSessions: number
@@ -24,10 +30,186 @@ interface SessionStats {
export const StatsCommand = cmd({
command: "stats",
handler: async () => {},
describe: "show token usage and cost statistics",
builder: (yargs: Argv) => {
return yargs
.option("days", {
describe: "show stats for the last N days (default: all time)",
type: "number",
})
.option("tools", {
describe: "number of tools to show (default: all)",
type: "number",
})
.option("project", {
describe: "filter by project (default: all projects, empty string: current project)",
type: "string",
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const stats = await aggregateSessionStats(args.days, args.project)
displayStats(stats, args.tools)
})
},
})
export function displayStats(stats: SessionStats) {
async function getCurrentProject(): Promise<Project.Info> {
return Instance.project
}
async function getAllSessions(): Promise<Session.Info[]> {
const sessions: Session.Info[] = []
const projectKeys = await Storage.list(["project"])
const projects = await Promise.all(projectKeys.map((key) => Storage.read<Project.Info>(key)))
for (const project of projects) {
if (!project) continue
const sessionKeys = await Storage.list(["session", project.id])
const projectSessions = await Promise.all(
sessionKeys.map((key) => Storage.read<Session.Info>(key)),
)
for (const session of projectSessions) {
if (session) {
sessions.push(session)
}
}
}
return sessions
}
async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
const sessions = await getAllSessions()
const DAYS_IN_SECOND = 24 * 60 * 60 * 1000
const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0
let filteredSessions = days
? sessions.filter((session) => session.time.updated >= cutoffTime)
: sessions
if (projectFilter !== undefined) {
if (projectFilter === "") {
const currentProject = await getCurrentProject()
filteredSessions = filteredSessions.filter(
(session) => session.projectID === currentProject.id,
)
} else {
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
}
}
const stats: SessionStats = {
totalSessions: filteredSessions.length,
totalMessages: 0,
totalCost: 0,
totalTokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
toolUsage: {},
dateRange: {
earliest: Date.now(),
latest: Date.now(),
},
days: 0,
costPerDay: 0,
}
if (filteredSessions.length > 1000) {
console.log(
`Large dataset detected (${filteredSessions.length} sessions). This may take a while...`,
)
}
if (filteredSessions.length === 0) {
return stats
}
let earliestTime = Date.now()
let latestTime = 0
const BATCH_SIZE = 20
for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
const batchPromises = batch.map(async (session) => {
const messages = await Session.messages(session.id)
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record<string, number> = {}
for (const message of messages) {
if (message.info.role === "assistant") {
sessionCost += message.info.cost || 0
if (message.info.tokens) {
sessionTokens.input += message.info.tokens.input || 0
sessionTokens.output += message.info.tokens.output || 0
sessionTokens.reasoning += message.info.tokens.reasoning || 0
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
}
}
for (const part of message.parts) {
if (part.type === "tool" && part.tool) {
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
}
}
}
return {
messageCount: messages.length,
sessionCost,
sessionTokens,
sessionToolUsage,
earliestTime: session.time.created,
latestTime: session.time.updated,
}
})
const batchResults = await Promise.all(batchPromises)
for (const result of batchResults) {
earliestTime = Math.min(earliestTime, result.earliestTime)
latestTime = Math.max(latestTime, result.latestTime)
stats.totalMessages += result.messageCount
stats.totalCost += result.sessionCost
stats.totalTokens.input += result.sessionTokens.input
stats.totalTokens.output += result.sessionTokens.output
stats.totalTokens.reasoning += result.sessionTokens.reasoning
stats.totalTokens.cache.read += result.sessionTokens.cache.read
stats.totalTokens.cache.write += result.sessionTokens.cache.write
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
}
}
}
const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND))
stats.dateRange = {
earliest: earliestTime,
latest: latestTime,
}
stats.days = actualDays
stats.costPerDay = stats.totalCost / actualDays
return stats
}
export function displayStats(stats: SessionStats, toolLimit?: number) {
const width = 56
function renderRow(label: string, value: string): string {
@@ -64,30 +246,35 @@ export function displayStats(stats: SessionStats) {
// Tool Usage section
if (Object.keys(stats.toolUsage).length > 0) {
const sortedTools = Object.entries(stats.toolUsage)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools
console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ TOOL USAGE │")
console.log("├────────────────────────────────────────────────────────┤")
const maxCount = Math.max(...sortedTools.map(([, count]) => count))
const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count))
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
for (const [tool, count] of sortedTools) {
for (const [tool, count] of toolsToDisplay) {
const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
const bar = "█".repeat(barLength)
const percentage = ((count / totalToolUsage) * 100).toFixed(1)
const content = ` ${tool.padEnd(10)} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
const padding = Math.max(0, width - content.length)
const maxToolLength = 18
const truncatedTool =
tool.length > maxToolLength ? tool.substring(0, maxToolLength - 2) + ".." : tool
const toolName = truncatedTool.padEnd(maxToolLength)
const content = ` ${toolName} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
const padding = Math.max(0, width - content.length - 1)
console.log(`${content}${" ".repeat(padding)}`)
}
console.log("└────────────────────────────────────────────────────────┘")
}
console.log()
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
@@ -177,6 +177,7 @@ function App() {
const exit = useExit()
useKeyboard(async (evt) => {
if (!Installation.isLocal()) return
if (evt.meta && evt.name === "t") {
renderer.toggleDebugOverlay()
return
@@ -18,6 +18,8 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>()
const deleteKeybind = "ctrl+d"
const options = createMemo(() => {
const today = new Date().toDateString()
return sync.data.session
@@ -30,7 +32,7 @@ export function DialogSessionList() {
}
const isDeleting = toDelete() === x.id
return {
title: isDeleting ? "Press delete again to confirm" : x.title,
title: isDeleting ? `Press ${deleteKeybind} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
@@ -60,7 +62,7 @@ export function DialogSessionList() {
}}
keybind={[
{
keybind: Keybind.parse("ctrl+d")[0],
keybind: Keybind.parse(deleteKeybind)[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
@@ -49,7 +49,12 @@ export function Autocomplete(props: {
})
const filter = createMemo(() => {
if (!store.visible) return
return props.value.substring(store.index + 1).split(" ")[0]
// Track props.value to make memo reactive to text changes
props.value // <- there surely is a better way to do this, like making .input() reactive
const val = props.input().getTextRange(store.index + 1, props.input().visualCursor.offset + 1)
return val
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
@@ -70,7 +75,7 @@ export function Autocomplete(props: {
const virtualText = "@" + text
const extmarkStart = store.index
const extmarkEnd = extmarkStart + virtualText.length
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
const styleId =
part.type === "file"
@@ -364,7 +369,7 @@ export function Autocomplete(props: {
return store.visible
},
onInput(value: string) {
if (store.visible && value.length <= store.index) hide()
if (store.visible && Bun.stringWidth(value) <= store.index) hide()
},
onKeyDown(e: KeyEvent) {
if (store.visible) {
@@ -378,7 +383,10 @@ export function Autocomplete(props: {
if (e.name === "@") {
const cursorOffset = props.input().visualCursor.offset
const charBeforeCursor =
cursorOffset === 0 ? undefined : props.value.at(cursorOffset - 1)
cursorOffset === 0
? undefined
: props.input().getTextRange(cursorOffset - 1, cursorOffset)
if (
charBeforeCursor === " " ||
charBeforeCursor === "\n" ||
@@ -712,7 +712,7 @@ export function Prompt(props: PromptProps) {
<Match when={props.hint}>{props.hint!}</Match>
<Match when={true}>
<text fg={theme.text}>
ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
</Switch>
@@ -73,6 +73,14 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
return store.leader
},
parse(evt: ParsedKey): Keybind.Info {
if (evt.name === "\x1F")
return {
ctrl: true,
name: "_",
shift: false,
leader: false,
meta: false,
}
return {
ctrl: evt.ctrl,
name: evt.name,
@@ -9,6 +9,7 @@ import { iife } from "@/util/iife"
import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast"
import { createEventBus } from "@solid-primitives/event-bus"
import { Provider } from "@/provider/provider"
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
@@ -38,7 +39,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent.set(props.initialAgent)
}
if (props.initialModel) {
const [providerID, modelID] = props.initialModel.split("/")
const { providerID, modelID } = Provider.parseModel(props.initialModel)
if (!providerID || !modelID)
return toast.show({
variant: "warning",
@@ -150,7 +151,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const fallbackModel = createMemo(() => {
if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/")
const { providerID, modelID } = Provider.parseModel(sync.data.config.model)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
@@ -243,8 +244,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const setInitialPrompt = createEventBus<string>()
onMount(() => {
if (props.initialPrompt)
setInitialPrompt.emit(props.initialPrompt)
if (props.initialPrompt) setInitialPrompt.emit(props.initialPrompt)
})
const result = {
@@ -1,17 +1,38 @@
import { createMemo, Match, Show, Switch } from "solid-js"
import { type Accessor, createMemo, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage } from "@opencode-ai/sdk"
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
const Title = (props: { session: Accessor<Session> }) => {
const { theme } = useTheme()
return (
<text fg={theme.text}>
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{props.session().title}</span>
</text>
)
}
const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Accessor<string> }) => {
const { theme } = useTheme()
return (
<Show when={props.context()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{props.context()} ({props.cost()})
</text>
</Show>
)
}
export function Header() {
const route = useRouteData("session")
const sync = useSync()
const { theme } = useTheme()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const cost = createMemo(() => {
const total = pipe(
@@ -43,6 +64,8 @@ export function Header() {
return result
})
const { theme } = useTheme()
return (
<box
paddingLeft={1}
@@ -51,31 +74,34 @@ export function Header() {
borderColor={theme.backgroundElement}
flexShrink={0}
>
<text fg={theme.text}>
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{session().title}</span>
</text>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexGrow={1} flexShrink={1}>
<Switch>
<Match when={session().share?.url}>
<text fg={theme.textMuted} wrapMode="word">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
<Show
when={shareEnabled()}
fallback={
<box flexDirection="row" justifyContent="space-between" gap={1}>
<Title session={session} />
<ContextInfo context={context} cost={cost} />
</box>
}
>
<Title session={session} />
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexGrow={1} flexShrink={1}>
<Switch>
<Match when={session().share?.url}>
<text fg={theme.textMuted} wrapMode="word">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
</box>
<ContextInfo context={context} cost={cost} />
</box>
<Show when={context()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{context()} ({cost()})
</text>
</Show>
</box>
</Show>
</box>
)
}
@@ -64,7 +64,6 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts"
import { Clipboard } from "../../util/clipboard"
import { Toast, useToast } from "../../ui/toast"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { useKV } from "../../context/kv.tsx"
addDefaultParsers(parsers.parsers)
@@ -401,12 +400,49 @@ export function Session() {
},
},
{
title: "Rename session",
value: "session.rename",
keybind: "session_rename",
title: "Copy last assistant message",
value: "messages.copy",
keybind: "messages_copy",
category: "Session",
onSelect: (dialog) => {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
const lastAssistantMessage = messages().findLast((msg) => msg.role === "assistant")
if (!lastAssistantMessage) {
toast.show({ message: "No assistant messages found", variant: "error" })
dialog.clear()
return
}
const parts = sync.data.part[lastAssistantMessage.id] ?? []
const textParts = parts.filter((part) => part.type === "text")
if (textParts.length === 0) {
toast.show({ message: "No text parts found in last assistant message", variant: "error" })
dialog.clear()
return
}
const text = textParts
.map((part) => part.text)
.join("\n")
.trim()
if (!text) {
toast.show({
message: "No text content found in last assistant message",
variant: "error",
})
dialog.clear()
return
}
console.log(text)
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
/* @ts-expect-error */
renderer.writeOut(finalOsc52)
Clipboard.copy(text)
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
dialog.clear()
},
},
{
@@ -1158,8 +1194,12 @@ ToolRegistry.register<typeof TaskTool>({
return (
<>
<ToolTitle icon="%" fallback="Delegating..." when={props.input.description}>
Task {props.input.description}
<ToolTitle
icon="%"
fallback="Delegating..."
when={props.input.subagent_type ?? props.input.description}
>
Task [{props.input.subagent_type ?? "unknown"}] {props.input.description}
</ToolTitle>
<Show when={props.metadata.summary?.length}>
<box>
@@ -2,6 +2,7 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { onMount } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export type DialogPromptProps = {
title: string
@@ -15,6 +16,13 @@ export function DialogPrompt(props: DialogPromptProps) {
const { theme } = useTheme()
let textarea: TextareaRenderable
useKeyboard((evt) => {
if (evt.name === "return") {
props.onConfirm?.(textarea.plainText)
dialog.clear()
}
})
onMount(() => {
dialog.setSize("large")
setTimeout(() => {
@@ -30,13 +30,13 @@ export namespace Clipboard {
}
if (os === "linux") {
const wayland = await $`wl-paste -t image/png`.nothrow().text()
if (wayland) {
return { data: Buffer.from(wayland).toString("base64url"), mime: "image/png" }
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
if (wayland && wayland.byteLength > 0) {
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
}
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().text()
if (x11) {
return { data: Buffer.from(x11).toString("base64url"), mime: "image/png" }
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
if (x11 && x11.byteLength > 0) {
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
}
}
@@ -47,7 +47,7 @@ export namespace Clipboard {
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64url"), mime: "image/png" }
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Server } from "../../server/server"
import { UI } from "../ui"
import { cmd } from "./cmd"
import open from "open"
export const WebCommand = cmd({
command: "web",
builder: (yargs) =>
yargs
.option("port", {
alias: ["p"],
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
describe: "starts a headless opencode server",
handler: async (args) => {
const hostname = args.hostname
const port = args.port
const server = Server.listen({
port,
hostname,
})
const url = `https://desktop.dev.opencode.ai?url=${server.url}`
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, url)
open(url).catch(() => {})
await new Promise(() => {})
await server.stop()
},
})
+4 -3
View File
@@ -4,9 +4,10 @@ import { NamedError } from "../util/error"
export namespace UI {
const LOGO = [
[`█▀▀█ █▀▀█ █▀▀ █▀▀▄ `, `█▀▀ █▀▀█ █▀▀▄ █▀▀`],
[`░░█ █░░█ █▀▀ █░░█ `, `░░ █░░█ █░░█ █▀▀`],
[`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `, `▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`],
[`  `, ``],
[`▀▀█ █▀▀█ █▀▀▀▀▄ `, `▀▀▀ █▀▀█ █▀▀█ █▀▀`],
[`█░░█ █░░█ █▀▀▀ █░░█ `, `█░░░ █░░█ █░░█ █▀▀▀`],
[`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ `, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`],
]
export const CancelledError = NamedError.create("UICancelledError", z.void())
+18 -6
View File
@@ -165,7 +165,11 @@ export namespace File {
const project = Instance.project
if (project.vcs !== "git") return []
const diffOutput = await $`git diff --numstat HEAD`.cwd(Instance.directory).quiet().nothrow().text()
const diffOutput = await $`git diff --numstat HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const changedFiles: Info[] = []
@@ -257,9 +261,14 @@ export namespace File {
if (project.vcs === "git") {
let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (!diff.trim())
diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (diff.trim()) {
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
const original = await $`git show HEAD:${file}`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
@@ -307,12 +316,15 @@ export namespace File {
})
}
export async function search(input: { query: string; limit?: number }) {
export async function search(input: { query: string; limit?: number; dirs?: boolean }) {
log.info("search", { query: input.query })
const limit = input.limit ?? 100
const result = await state().then((x) => x.files())
if (!input.query) return result.dirs.toSorted().slice(0, limit)
const items = [...result.files, ...result.dirs]
if (!input.query)
return input.dirs !== false
? result.dirs.toSorted().slice(0, limit)
: result.files.slice(0, limit)
const items = input.dirs !== false ? [...result.files, ...result.dirs] : result.files
const sorted = fuzzysort.go(input.query, items, { limit: limit }).map((r) => r.target)
log.info("search", { query: input.query, results: sorted.length })
return sorted
+2
View File
@@ -22,6 +22,7 @@ import { TuiThreadCommand } from "./cli/cmd/tui/thread"
import { TuiSpawnCommand } from "./cli/cmd/tui/spawn"
import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
@@ -81,6 +82,7 @@ const cli = yargs(hideBin(process.argv))
.command(AgentCommand)
.command(UpgradeCommand)
.command(ServeCommand)
.command(WebCommand)
.command(ModelsCommand)
.command(StatsCommand)
.command(ExportCommand)
+24 -12
View File
@@ -100,7 +100,7 @@ export namespace MCP {
}
log.info("found", { key, type: mcp.type })
let mcpClient: MCPClient | undefined
let status: Status | undefined
let status: Status | undefined = undefined
if (mcp.type === "remote") {
const transports = [
@@ -142,7 +142,7 @@ export namespace MCP {
error: lastError.message,
})
status = {
status: "failed",
status: "failed" as const,
error: lastError.message,
}
return false
@@ -179,7 +179,7 @@ export namespace MCP {
error: error instanceof Error ? error.message : String(error),
})
status = {
status: "failed",
status: "failed" as const,
error: error instanceof Error ? error.message : String(error),
}
})
@@ -187,7 +187,7 @@ export namespace MCP {
if (!status) {
status = {
status: "failed",
status: "failed" as const,
error: "Unknown error",
}
}
@@ -202,13 +202,12 @@ export namespace MCP {
const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => {})
if (!result) {
await mcpClient.close()
status = {
status: "failed",
error: "Failed to get tools",
}
return {
mcpClient: undefined,
status,
status: {
status: "failed" as const,
error: "Failed to get tools",
},
}
}
@@ -228,10 +227,23 @@ export namespace MCP {
export async function tools() {
const result: Record<string, Tool> = {}
const s = await state()
for (const [clientName, client] of Object.entries(await clients())) {
for (const [toolName, tool] of Object.entries(await client.tools())) {
const sanitizedClientName = clientName.replace(/\s+/g, "_")
const sanitizedToolName = toolName.replace(/[-\s]+/g, "_")
const tools = await client.tools().catch((e) => {
log.error("failed to get tools", { clientName, error: e.message })
const failedStatus = {
status: "failed" as const,
error: e instanceof Error ? e.message : String(e),
}
s.status[clientName] = failedStatus
delete s.clients[clientName]
})
if (!tools) {
continue
}
for (const [toolName, tool] of Object.entries(tools)) {
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, "_")
result[sanitizedClientName + "_" + sanitizedToolName] = tool
}
}
+3
View File
@@ -1106,13 +1106,16 @@ export namespace Server {
"query",
z.object({
query: z.string(),
dirs: z.union([z.literal("true"), z.literal("false")]).optional(),
}),
),
async (c) => {
const query = c.req.valid("query").query
const dirs = c.req.valid("query").dirs
const results = await File.search({
query,
limit: 10,
dirs: dirs !== "false",
})
return c.json(results)
},
+1 -1
View File
@@ -27,7 +27,7 @@ export abstract class NamedError extends Error {
}
static isInstance(input: any): input is InstanceType<typeof result> {
return "name" in input && input.name === name
return typeof input === "object" && "name" in input && input.name === name
}
schema() {
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { iife } from "../../src/util/iife"
describe("util.iife", () => {
test("should execute function immediately and return result", () => {
let called = false
const result = iife(() => {
called = true
return 42
})
expect(called).toBe(true)
expect(result).toBe(42)
})
test("should work with async functions", async () => {
let called = false
const result = await iife(async () => {
called = true
return "async result"
})
expect(called).toBe(true)
expect(result).toBe("async result")
})
test("should handle functions with no return value", () => {
let called = false
const result = iife(() => {
called = true
})
expect(called).toBe(true)
expect(result).toBeUndefined()
})
})
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test"
import { lazy } from "../../src/util/lazy"
describe("util.lazy", () => {
test("should call function only once", () => {
let callCount = 0
const getValue = () => {
callCount++
return "expensive value"
}
const lazyValue = lazy(getValue)
expect(callCount).toBe(0)
const result1 = lazyValue()
expect(result1).toBe("expensive value")
expect(callCount).toBe(1)
const result2 = lazyValue()
expect(result2).toBe("expensive value")
expect(callCount).toBe(1)
})
test("should preserve the same reference", () => {
const obj = { value: 42 }
const lazyObj = lazy(() => obj)
const result1 = lazyObj()
const result2 = lazyObj()
expect(result1).toBe(obj)
expect(result2).toBe(obj)
expect(result1).toBe(result2)
})
test("should work with different return types", () => {
const lazyString = lazy(() => "string")
const lazyNumber = lazy(() => 123)
const lazyBoolean = lazy(() => true)
const lazyNull = lazy(() => null)
const lazyUndefined = lazy(() => undefined)
expect(lazyString()).toBe("string")
expect(lazyNumber()).toBe(123)
expect(lazyBoolean()).toBe(true)
expect(lazyNull()).toBe(null)
expect(lazyUndefined()).toBe(undefined)
})
})
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test"
import { withTimeout } from "../../src/util/timeout"
describe("util.timeout", () => {
test("should resolve when promise completes before timeout", async () => {
const fastPromise = new Promise<string>((resolve) => {
setTimeout(() => resolve("fast"), 10)
})
const result = await withTimeout(fastPromise, 100)
expect(result).toBe("fast")
})
test("should reject when promise exceeds timeout", async () => {
const slowPromise = new Promise<string>((resolve) => {
setTimeout(() => resolve("slow"), 200)
})
await expect(withTimeout(slowPromise, 50)).rejects.toThrow("Operation timed out after 50ms")
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.14",
"version": "1.0.19",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+9
View File
@@ -0,0 +1,9 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.14",
"version": "1.0.19",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1
View File
@@ -2346,6 +2346,7 @@ export type FindFilesData = {
query: {
directory?: string
query: string
dirs?: "true" | "false"
}
url: "/find/file"
}
+83
View File
@@ -0,0 +1,83 @@
# Automatically generated by SST
# pylint: disable=all
from typing import Any
class Resource:
class ADMIN_SECRET:
type: str
value: str
class AUTH_API_URL:
type: str
value: str
class AWS_SES_ACCESS_KEY_ID:
type: str
value: str
class AWS_SES_SECRET_ACCESS_KEY:
type: str
value: str
class Api:
type: str
url: str
class App:
name: str
stage: str
class AuthApi:
type: str
url: str
class AuthStorage:
type: str
class Bucket:
name: str
type: str
class Console:
type: str
url: str
class Database:
database: str
host: str
password: str
port: float
type: str
username: str
class Desktop:
type: str
url: str
class EMAILOCTOPUS_API_KEY:
type: str
value: str
class GITHUB_APP_ID:
type: str
value: str
class GITHUB_APP_PRIVATE_KEY:
type: str
value: str
class GITHUB_CLIENT_ID_CONSOLE:
type: str
value: str
class GITHUB_CLIENT_SECRET_CONSOLE:
type: str
value: str
class GOOGLE_CLIENT_ID:
type: str
value: str
class HONEYCOMB_API_KEY:
type: str
value: str
class LogProcessor:
type: str
class STRIPE_SECRET_KEY:
type: str
value: str
class STRIPE_WEBHOOK_SECRET:
type: str
value: str
class Web:
type: str
url: str
class ZEN_MODELS1:
type: str
value: str
class ZEN_MODELS2:
type: str
value: str
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.14",
"version": "1.0.19",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+8 -2
View File
@@ -1,3 +1,9 @@
/// <reference types="../../../sst-env.d.ts" />
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
export {}
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.14",
"version": "1.0.19",
"type": "module",
"exports": {
".": "./src/components/index.ts",
+3
View File
@@ -0,0 +1,3 @@
[data-component="code"] {
overflow: hidden;
}
+52
View File
@@ -0,0 +1,52 @@
import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/precision-diffs"
import { ComponentProps, createEffect, splitProps } from "solid-js"
export type CodeProps<T = {}> = FileOptions<T> & {
file: FileContents
annotations?: LineAnnotation<T>[]
class?: string
classList?: ComponentProps<"div">["classList"]
}
export function Code<T>(props: CodeProps<T>) {
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["file", "class", "classList", "annotations"])
const file = () => local.file
createEffect(() => {
const instance = new File<T>({
theme: { dark: "oc-1-dark", light: "oc-1-light" }, // or any Shiki theme
overflow: "wrap", // or 'scroll'
themeType: "system", // 'system', 'light', or 'dark'
disableLineNumbers: false, // optional
// lang: 'typescript', // optional - auto-detected from filename if not provided
...others,
})
instance.render({
file: file(),
lineAnnotations: local.annotations,
containerWrapper: container,
})
})
return (
<div
data-component="code"
style={{
"--pjs-font-family": "var(--font-family-mono)",
"--pjs-font-size": "var(--font-size-small)",
"--pjs-line-height": "24px",
"--pjs-tab-size": 2,
"--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
"--pjs-header-font-family": "var(--font-family-sans)",
"--pjs-gap-block": 0,
}}
classList={{
...(local.classList || {}),
[local.class ?? ""]: !!local.class,
}}
ref={container}
/>
)
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,6 +2,7 @@ export * from "./accordion"
export * from "./button"
export * from "./card"
export * from "./checkbox"
export * from "./code"
export * from "./collapsible"
export * from "./dialog"
export * from "./diff"
+1
View File
@@ -10,6 +10,7 @@
@import "../components/button.css" layer(components);
@import "../components/card.css" layer(components);
@import "../components/checkbox.css" layer(components);
@import "../components/code.css" layer(components);
@import "../components/diff.css" layer(components);
@import "../components/diff-changes.css" layer(components);
@import "../components/collapsible.css" layer(components);
+9
View File
@@ -0,0 +1,9 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.14",
"version": "1.0.19",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+4
View File
@@ -43,6 +43,10 @@ We removed some functionality that we weren't sure anyone actually used. If some
## Breaking changes
### Theme
The `system` theme has not yet been ported and custom themes aren't loaded yet but both of these will be fixed this week.
### Keybinds renamed
- messages_revert -> messages_undo
@@ -23,6 +23,8 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
"session_unshare": "none",
"session_interrupt": "escape",
"session_compact": "<leader>c",
"session_child_cycle": "ctrl+right",
"session_child_cycle_reverse": "ctrl+left",
"messages_page_up": "pageup",
"messages_page_down": "pagedown",
"messages_half_page_up": "ctrl+alt+u",
+1 -1
View File
@@ -114,7 +114,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Model | Input | Output | Cached Read | Cached Write |
| --------------------------------- | ------ | ------ | ----------- | ------------ |
| GLM 4.6 | $0.60 | $1.90 | $0.11 | - |
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
| Kimi K2 | $0.60 | $2.50 | $0.36 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Grok Code Fast 1 | Free | Free | - | - |
+1
View File
@@ -103,5 +103,6 @@ if (!Script.preview) {
await $`git fetch origin`
await $`git cherry-pick HEAD..origin/dev`.nothrow()
await $`git push origin HEAD --tags --no-verify --force-with-lease`
await new Promise((resolve) => setTimeout(resolve, 5_000))
await $`gh release create v${Script.version} --title "v${Script.version}" --notes ${notes.join("\n") ?? "No notable changes"} ./packages/opencode/dist/*.zip`
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.14",
"version": "1.0.19",
"publisher": "sst-dev",
"repository": {
"type": "git",
+9 -1
View File
@@ -5,6 +5,10 @@
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
@@ -91,7 +95,11 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"ZEN_MODELS": {
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
}