mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aaddd532aa | |||
| 9c237f0bfb | |||
| 63bfe76720 | |||
| 99d7ff47c4 | |||
| 3ff0eb3065 | |||
| 4d2b265dc4 | |||
| 1854245bd3 | |||
| 4d07034930 | |||
| 98031173b6 | |||
| e8e474597c | |||
| 382758790c | |||
| c33920f59d | |||
| 33f004d4b6 | |||
| 8963b536ee | |||
| 51455e2a1e | |||
| 30d6a26e3e | |||
| cd4fabd11b | |||
| 9a8b8f26ac | |||
| 2f73b16b57 | |||
| df9952c291 | |||
| ee946d8128 | |||
| ec8f2e078e | |||
| 335f46122b | |||
| 73eae191e9 | |||
| 14e823e938 | |||
| 2fbd462e6e | |||
| e1cc98d448 | |||
| 0ce64962d4 | |||
| 338229193f | |||
| 57644a4be8 | |||
| da2099137a | |||
| 09bc8d9ca4 | |||
| d95f724303 | |||
| c413c3ed8f | |||
| 5f56be0ad4 | |||
| ef441d5cff | |||
| 16a188c524 | |||
| 50c40a8d99 | |||
| 4114c8715c | |||
| ced5fdbe70 | |||
| b16aa81e0d | |||
| b44971668c | |||
| 0ff4c284e2 | |||
| e8db95be16 | |||
| 69c2dd53ad | |||
| 14a910bd64 | |||
| 52f97ffdc9 | |||
| a1e87f6cd9 | |||
| c2fc41dcd5 | |||
| b62c7943e7 | |||
| 64caeeb12d | |||
| e8ac4a1e99 | |||
| 19c8654195 | |||
| 00d7aed797 | |||
| 4477132448 | |||
| eaeea45ace | |||
| e404bf33b1 | |||
| 79a7edea5e | |||
| 2b05fe2859 | |||
| f8996f0a90 | |||
| eb04cdac41 |
@@ -3,6 +3,8 @@ name: opencode
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
opencode:
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- fix-snapshot-2
|
||||
- test-bedrock
|
||||
- v0
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": ["opencode-openai-codex-auth"],
|
||||
// "enterprise": {
|
||||
// "url": "http://localhost:3000",
|
||||
// "url": "https://enterprise.dev.opencode.ai",
|
||||
// },
|
||||
"provider": {
|
||||
"opencode": {
|
||||
@@ -11,4 +11,10 @@
|
||||
},
|
||||
},
|
||||
},
|
||||
"mcp": {
|
||||
"exa": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.exa.ai/mcp",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -151,3 +151,4 @@
|
||||
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
|
||||
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
|
||||
| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) |
|
||||
| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) |
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -74,7 +74,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -122,7 +122,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -163,7 +163,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -191,7 +191,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -207,7 +207,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -294,7 +294,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -314,7 +314,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.81.0",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -325,7 +325,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -338,7 +338,7 @@
|
||||
},
|
||||
"packages/tauri": {
|
||||
"name": "@opencode-ai/tauri",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
@@ -351,7 +351,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -383,7 +383,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -393,7 +393,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -439,7 +439,7 @@
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/precision-diffs": "0.5.5",
|
||||
"@pierre/precision-diffs": "0.5.7",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||
@@ -1214,7 +1214,7 @@
|
||||
|
||||
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
|
||||
|
||||
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.5.5", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-mmDHEWWQ6fmXY5qRNHqodzOxHPwLqVNbbnO/MOpXteOTjd0nVIGy5IcaNwU2WSxhxQRwaUepKyx5+wwPcZLEmw=="],
|
||||
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.5.7", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-Y+e4kJ9pT2I4NS5fE39KdoiXtwMkVPRvrwLM6O2IqO7PDCRWLBS7CYxcSgSyngEndccUll2krx66I2QnfO0Ovg=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1763934636,
|
||||
"narHash": "sha256-9glbI7f1uU+yzQCq5LwLgdZqx6svOhZWkd4JRY265fc=",
|
||||
"lastModified": 1764081664,
|
||||
"narHash": "sha256-sUoHmPr/EwXzRMpv1u/kH+dXuvJEyyF2Q7muE+t0EU4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ee09932cedcef15aaf476f9343d1dea2cb77e261",
|
||||
"rev": "dc205f7b4fdb04c8b7877b43edb7b73be7730081",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -30,6 +30,24 @@ Leave the following comment on a GitHub PR. opencode will implement the requeste
|
||||
Delete the attachment from S3 when the note is removed /oc
|
||||
```
|
||||
|
||||
#### Review specific code lines
|
||||
|
||||
Leave a comment directly on code lines in the PR's "Files" tab. opencode will automatically detect the file, line numbers, and diff context to provide precise responses.
|
||||
|
||||
```
|
||||
[Comment on specific lines in Files tab]
|
||||
/oc add error handling here
|
||||
```
|
||||
|
||||
When commenting on specific lines, opencode receives:
|
||||
|
||||
- The exact file being reviewed
|
||||
- The specific lines of code
|
||||
- The surrounding diff context
|
||||
- Line number information
|
||||
|
||||
This allows for more targeted requests without needing to specify file paths or line numbers manually.
|
||||
|
||||
## Installation
|
||||
|
||||
Run the following command in the terminal from your GitHub repo:
|
||||
@@ -51,6 +69,8 @@ This will walk you through installing the GitHub app, creating the workflow, and
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
opencode:
|
||||
@@ -135,3 +155,9 @@ Replace the image URL `https://github.com/user-attachments/assets/xxxxxxxx` with
|
||||
```
|
||||
MOCK_EVENT='{"eventName":"issue_comment","repo":{"owner":"sst","repo":"hello-world"},"actor":"fwang","payload":{"issue":{"number":4,"pull_request":{}},"comment":{"id":1,"body":"hey opencode, summarize thread"}}}'
|
||||
```
|
||||
|
||||
### PR review comment event
|
||||
|
||||
```
|
||||
MOCK_EVENT='{"eventName":"pull_request_review_comment","repo":{"owner":"sst","repo":"hello-world"},"actor":"fwang","payload":{"pull_request":{"number":7},"comment":{"id":1,"body":"hey opencode, add error handling","path":"src/components/Button.tsx","diff_hunk":"@@ -45,8 +45,11 @@\n- const handleClick = () => {\n- console.log('clicked')\n+ const handleClick = useCallback(() => {\n+ console.log('clicked')\n+ doSomething()\n+ }, [doSomething])","line":47,"original_line":45,"position":10,"commit_id":"abc123","original_commit_id":"def456"}}}'
|
||||
```
|
||||
|
||||
+44
-7
@@ -5,7 +5,7 @@ import { graphql } from "@octokit/graphql"
|
||||
import * as core from "@actions/core"
|
||||
import * as github from "@actions/github"
|
||||
import type { Context as GitHubContext } from "@actions/github/lib/context"
|
||||
import type { IssueCommentEvent } from "@octokit/webhooks-types"
|
||||
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { spawn } from "node:child_process"
|
||||
|
||||
@@ -124,7 +124,7 @@ let exitCode = 0
|
||||
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
|
||||
|
||||
try {
|
||||
assertContextEvent("issue_comment")
|
||||
assertContextEvent("issue_comment", "pull_request_review_comment")
|
||||
assertPayloadKeyword()
|
||||
await assertOpencodeConnected()
|
||||
|
||||
@@ -241,19 +241,43 @@ function createOpencode() {
|
||||
}
|
||||
|
||||
function assertPayloadKeyword() {
|
||||
const payload = useContext().payload as IssueCommentEvent
|
||||
const payload = useContext().payload as IssueCommentEvent | PullRequestReviewCommentEvent
|
||||
const body = payload.comment.body.trim()
|
||||
if (!body.match(/(?:^|\s)(?:\/opencode|\/oc)(?=$|\s)/)) {
|
||||
throw new Error("Comments must mention `/opencode` or `/oc`")
|
||||
}
|
||||
}
|
||||
|
||||
function getReviewCommentContext() {
|
||||
const context = useContext()
|
||||
if (context.eventName !== "pull_request_review_comment") {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload = context.payload as PullRequestReviewCommentEvent
|
||||
return {
|
||||
file: payload.comment.path,
|
||||
diffHunk: payload.comment.diff_hunk,
|
||||
line: payload.comment.line,
|
||||
originalLine: payload.comment.original_line,
|
||||
position: payload.comment.position,
|
||||
commitId: payload.comment.commit_id,
|
||||
originalCommitId: payload.comment.original_commit_id,
|
||||
}
|
||||
}
|
||||
|
||||
async function assertOpencodeConnected() {
|
||||
let retry = 0
|
||||
let connected = false
|
||||
do {
|
||||
try {
|
||||
await client.app.get<true>()
|
||||
await client.app.log<true>({
|
||||
body: {
|
||||
service: "github-workflow",
|
||||
level: "info",
|
||||
message: "Prepare to react to Github Workflow event",
|
||||
},
|
||||
})
|
||||
connected = true
|
||||
break
|
||||
} catch (e) {}
|
||||
@@ -383,11 +407,24 @@ async function createComment() {
|
||||
}
|
||||
|
||||
async function getUserPrompt() {
|
||||
const context = useContext()
|
||||
const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
|
||||
const reviewContext = getReviewCommentContext()
|
||||
|
||||
let prompt = (() => {
|
||||
const payload = useContext().payload as IssueCommentEvent
|
||||
const body = payload.comment.body.trim()
|
||||
if (body === "/opencode" || body === "/oc") return "Summarize this thread"
|
||||
if (body.includes("/opencode") || body.includes("/oc")) return body
|
||||
if (body === "/opencode" || body === "/oc") {
|
||||
if (reviewContext) {
|
||||
return `Review this code change and suggest improvements for the commented lines:\n\nFile: ${reviewContext.file}\nLines: ${reviewContext.line}\n\n${reviewContext.diffHunk}`
|
||||
}
|
||||
return "Summarize this thread"
|
||||
}
|
||||
if (body.includes("/opencode") || body.includes("/oc")) {
|
||||
if (reviewContext) {
|
||||
return `${body}\n\nContext: You are reviewing a comment on file "${reviewContext.file}" at line ${reviewContext.line}.\n\nDiff context:\n${reviewContext.diffHunk}`
|
||||
}
|
||||
return body
|
||||
}
|
||||
throw new Error("Comments must mention `/opencode` or `/oc`")
|
||||
})()
|
||||
|
||||
|
||||
@@ -11,43 +11,82 @@ requested_version=${VERSION:-}
|
||||
|
||||
raw_os=$(uname -s)
|
||||
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
||||
# Normalize various Unix-like identifiers
|
||||
case "$raw_os" in
|
||||
Darwin*) os="darwin" ;;
|
||||
Linux*) os="linux" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
|
||||
esac
|
||||
arch=$(uname -m)
|
||||
esac
|
||||
|
||||
arch=$(uname -m)
|
||||
if [[ "$arch" == "aarch64" ]]; then
|
||||
arch="arm64"
|
||||
elif [[ "$arch" == "x86_64" ]]; then
|
||||
fi
|
||||
if [[ "$arch" == "x86_64" ]]; then
|
||||
arch="x64"
|
||||
fi
|
||||
|
||||
if [ "$os" = "linux" ]; then
|
||||
filename="$APP-$os-$arch.tar.gz"
|
||||
else
|
||||
filename="$APP-$os-$arch.zip"
|
||||
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
|
||||
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
|
||||
if [ "$rosetta_flag" = "1" ]; then
|
||||
arch="arm64"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
case "$filename" in
|
||||
*"-linux-"*)
|
||||
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
|
||||
combo="$os-$arch"
|
||||
case "$combo" in
|
||||
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
|
||||
;;
|
||||
*"-darwin-"*)
|
||||
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
|
||||
;;
|
||||
*"-windows-"*)
|
||||
[[ "$arch" == "x64" ]] || exit 1
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
exit 1
|
||||
*)
|
||||
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
archive_ext=".zip"
|
||||
if [ "$os" = "linux" ]; then
|
||||
archive_ext=".tar.gz"
|
||||
fi
|
||||
|
||||
is_musl=false
|
||||
if [ "$os" = "linux" ]; then
|
||||
if [ -f /etc/alpine-release ]; then
|
||||
is_musl=true
|
||||
fi
|
||||
|
||||
if command -v ldd >/dev/null 2>&1; then
|
||||
if ldd --version 2>&1 | grep -qi musl; then
|
||||
is_musl=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
needs_baseline=false
|
||||
if [ "$arch" = "x64" ]; then
|
||||
if [ "$os" = "linux" ]; then
|
||||
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
|
||||
needs_baseline=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$os" = "darwin" ]; then
|
||||
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
|
||||
if [ "$avx2" != "1" ]; then
|
||||
needs_baseline=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
target="$os-$arch"
|
||||
if [ "$needs_baseline" = "true" ]; then
|
||||
target="$target-baseline"
|
||||
fi
|
||||
if [ "$is_musl" = "true" ]; then
|
||||
target="$target-musl"
|
||||
fi
|
||||
|
||||
filename="$APP-$target$archive_ext"
|
||||
|
||||
|
||||
if [ "$os" = "linux" ]; then
|
||||
if ! command -v tar >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"nodeModules": "sha256-cieNNPXZd4Bg9bZtRq2H8L99e24U8p5d+d76SE7SeJc="
|
||||
"nodeModules": "sha256-XFJXjBWbHIUWKdSOZkGW7VmUPBVtGRgLHM2/1xVThFc="
|
||||
}
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/precision-diffs": "0.5.5",
|
||||
"@pierre/precision-diffs": "0.5.7",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"ai": "5.0.97",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -13,13 +13,20 @@ import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
||||
import { logger } from "./logger"
|
||||
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
|
||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
|
||||
import {
|
||||
createBodyConverter,
|
||||
createStreamPartConverter,
|
||||
createResponseConverter,
|
||||
ProviderHelper,
|
||||
UsageInfo,
|
||||
} from "./provider/provider"
|
||||
import { anthropicHelper } from "./provider/anthropic"
|
||||
import { googleHelper } from "./provider/google"
|
||||
import { openaiHelper } from "./provider/openai"
|
||||
import { oaCompatHelper } from "./provider/openai-compatible"
|
||||
import { createRateLimiter } from "./rateLimiter"
|
||||
import { createDataDumper } from "./dataDumper"
|
||||
import { createTrialLimiter } from "./trialLimiter"
|
||||
|
||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||
type RetryOptions = {
|
||||
@@ -62,11 +69,13 @@ export async function handler(
|
||||
const zenData = ZenData.list()
|
||||
const modelInfo = validateModel(zenData, model)
|
||||
const dataDumper = createDataDumper(sessionId, requestId)
|
||||
const trialLimiter = createTrialLimiter(modelInfo.trial?.limit, ip)
|
||||
const isTrial = await trialLimiter?.isTrial()
|
||||
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
|
||||
await rateLimiter?.check()
|
||||
|
||||
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
||||
const providerInfo = selectProvider(zenData, modelInfo, sessionId, retry)
|
||||
const providerInfo = selectProvider(zenData, modelInfo, sessionId, isTrial ?? false, retry)
|
||||
const authInfo = await authenticate(modelInfo, providerInfo)
|
||||
validateBilling(authInfo, modelInfo)
|
||||
validateModelSettings(authInfo)
|
||||
@@ -136,8 +145,10 @@ export async function handler(
|
||||
logger.debug("RESPONSE: " + body)
|
||||
dataDumper?.provideResponse(body)
|
||||
dataDumper?.flush()
|
||||
const tokensInfo = providerInfo.normalizeUsage(json.usage)
|
||||
await trialLimiter?.track(tokensInfo)
|
||||
await rateLimiter?.track()
|
||||
await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
|
||||
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
||||
await reload(authInfo)
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
@@ -169,7 +180,9 @@ export async function handler(
|
||||
await rateLimiter?.track()
|
||||
const usage = usageParser.retrieve()
|
||||
if (usage) {
|
||||
await trackUsage(authInfo, modelInfo, providerInfo, usage)
|
||||
const tokensInfo = providerInfo.normalizeUsage(usage)
|
||||
await trialLimiter?.track(tokensInfo)
|
||||
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
||||
await reload(authInfo)
|
||||
}
|
||||
c.close()
|
||||
@@ -275,8 +288,18 @@ export async function handler(
|
||||
return { id: modelId, ...modelData }
|
||||
}
|
||||
|
||||
function selectProvider(zenData: ZenData, modelInfo: ModelInfo, sessionId: string, retry: RetryOptions) {
|
||||
function selectProvider(
|
||||
zenData: ZenData,
|
||||
modelInfo: ModelInfo,
|
||||
sessionId: string,
|
||||
isTrial: boolean,
|
||||
retry: RetryOptions,
|
||||
) {
|
||||
const provider = (() => {
|
||||
if (isTrial) {
|
||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider)
|
||||
}
|
||||
|
||||
if (retry.retryCount === MAX_RETRIES) {
|
||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
|
||||
}
|
||||
@@ -432,9 +455,14 @@ export async function handler(
|
||||
providerInfo.apiKey = authInfo.provider.credentials
|
||||
}
|
||||
|
||||
async function trackUsage(authInfo: AuthInfo, modelInfo: ModelInfo, providerInfo: ProviderInfo, usage: any) {
|
||||
async function trackUsage(
|
||||
authInfo: AuthInfo,
|
||||
modelInfo: ModelInfo,
|
||||
providerInfo: ProviderInfo,
|
||||
usageInfo: UsageInfo,
|
||||
) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
providerInfo.normalizeUsage(usage)
|
||||
usageInfo
|
||||
|
||||
const modelCost =
|
||||
modelInfo.cost200K &&
|
||||
|
||||
@@ -24,6 +24,15 @@ import {
|
||||
toOaCompatibleResponse,
|
||||
} from "./openai-compatible"
|
||||
|
||||
export type UsageInfo = {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWrite5mTokens?: number
|
||||
cacheWrite1hTokens?: number
|
||||
}
|
||||
|
||||
export type ProviderHelper = {
|
||||
format: ZenData.Format
|
||||
modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => string
|
||||
@@ -34,14 +43,7 @@ export type ProviderHelper = {
|
||||
parse: (chunk: string) => void
|
||||
retrieve: () => any
|
||||
}
|
||||
normalizeUsage: (usage: any) => {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWrite5mTokens?: number
|
||||
cacheWrite1hTokens?: number
|
||||
}
|
||||
normalizeUsage: (usage: any) => UsageInfo
|
||||
}
|
||||
|
||||
export interface CommonMessage {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { UsageInfo } from "./provider/provider"
|
||||
|
||||
export function createTrialLimiter(limit: number | undefined, ip: string) {
|
||||
if (!limit) return
|
||||
if (!ip) return
|
||||
|
||||
let trial: boolean
|
||||
|
||||
return {
|
||||
isTrial: async () => {
|
||||
const data = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
usage: IpTable.usage,
|
||||
})
|
||||
.from(IpTable)
|
||||
.where(eq(IpTable.ip, ip))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
trial = (data?.usage ?? 0) < limit
|
||||
return trial
|
||||
},
|
||||
track: async (usageInfo: UsageInfo) => {
|
||||
if (!trial) return
|
||||
const usage =
|
||||
usageInfo.inputTokens +
|
||||
usageInfo.outputTokens +
|
||||
(usageInfo.reasoningTokens ?? 0) +
|
||||
(usageInfo.cacheReadTokens ?? 0) +
|
||||
(usageInfo.cacheWrite5mTokens ?? 0) +
|
||||
(usageInfo.cacheWrite1hTokens ?? 0)
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.insert(IpTable)
|
||||
.values({ ip, usage })
|
||||
.onDuplicateKeyUpdate({ set: { usage: sql`${IpTable.usage} + ${usage}` } }),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE `ip` (
|
||||
`ip` varchar(45) NOT NULL,
|
||||
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
|
||||
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`time_deleted` timestamp(3),
|
||||
`usage` int,
|
||||
CONSTRAINT `ip_ip_pk` PRIMARY KEY(`ip`)
|
||||
);
|
||||
@@ -0,0 +1,981 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "9d5d9885-7ec5-45f6-ac53-45a8e25dede7",
|
||||
"prevId": "8b7fa839-a088-408e-84a4-1a07325c0290",
|
||||
"tables": {
|
||||
"account": {
|
||||
"name": "account",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"account_id_pk": {
|
||||
"name": "account_id_pk",
|
||||
"columns": ["id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"auth": {
|
||||
"name": "auth",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "enum('email','github','google')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"subject": {
|
||||
"name": "subject",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"columns": ["provider", "subject"],
|
||||
"isUnique": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"columns": ["account_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"auth_id_pk": {
|
||||
"name": "auth_id_pk",
|
||||
"columns": ["id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"billing": {
|
||||
"name": "billing",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"customer_id": {
|
||||
"name": "customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"payment_method_id": {
|
||||
"name": "payment_method_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"payment_method_type": {
|
||||
"name": "payment_method_type",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"payment_method_last4": {
|
||||
"name": "payment_method_last4",
|
||||
"type": "varchar(4)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"balance": {
|
||||
"name": "balance",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"monthly_limit": {
|
||||
"name": "monthly_limit",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"monthly_usage": {
|
||||
"name": "monthly_usage",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_monthly_usage_updated": {
|
||||
"name": "time_monthly_usage_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reload": {
|
||||
"name": "reload",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reload_trigger": {
|
||||
"name": "reload_trigger",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reload_amount": {
|
||||
"name": "reload_amount",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reload_error": {
|
||||
"name": "reload_error",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_reload_error": {
|
||||
"name": "time_reload_error",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_reload_locked_till": {
|
||||
"name": "time_reload_locked_till",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"global_customer_id": {
|
||||
"name": "global_customer_id",
|
||||
"columns": ["customer_id"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"billing_workspace_id_id_pk": {
|
||||
"name": "billing_workspace_id_id_pk",
|
||||
"columns": ["workspace_id", "id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"payment": {
|
||||
"name": "payment",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"customer_id": {
|
||||
"name": "customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"invoice_id": {
|
||||
"name": "invoice_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"payment_id": {
|
||||
"name": "payment_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"amount": {
|
||||
"name": "amount",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_refunded": {
|
||||
"name": "time_refunded",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"payment_workspace_id_id_pk": {
|
||||
"name": "payment_workspace_id_id_pk",
|
||||
"columns": ["workspace_id", "id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"usage": {
|
||||
"name": "usage",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"input_tokens": {
|
||||
"name": "input_tokens",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"output_tokens": {
|
||||
"name": "output_tokens",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning_tokens": {
|
||||
"name": "reasoning_tokens",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cache_read_tokens": {
|
||||
"name": "cache_read_tokens",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cache_write_5m_tokens": {
|
||||
"name": "cache_write_5m_tokens",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cache_write_1h_tokens": {
|
||||
"name": "cache_write_1h_tokens",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cost": {
|
||||
"name": "cost",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key_id": {
|
||||
"name": "key_id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"usage_workspace_id_id_pk": {
|
||||
"name": "usage_workspace_id_id_pk",
|
||||
"columns": ["workspace_id", "id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"columns": {
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "varchar(45)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"usage": {
|
||||
"name": "usage",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"ip_ip_pk": {
|
||||
"name": "ip_ip_pk",
|
||||
"columns": ["ip"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_used": {
|
||||
"name": "time_used",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"global_key": {
|
||||
"name": "global_key",
|
||||
"columns": ["key"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"key_workspace_id_id_pk": {
|
||||
"name": "key_workspace_id_id_pk",
|
||||
"columns": ["workspace_id", "id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"model_workspace_model": {
|
||||
"name": "model_workspace_model",
|
||||
"columns": ["workspace_id", "model"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"model_workspace_id_id_pk": {
|
||||
"name": "model_workspace_id_id_pk",
|
||||
"columns": ["workspace_id", "id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"credentials": {
|
||||
"name": "credentials",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"workspace_provider": {
|
||||
"name": "workspace_provider",
|
||||
"columns": ["workspace_id", "provider"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"provider_workspace_id_id_pk": {
|
||||
"name": "provider_workspace_id_id_pk",
|
||||
"columns": ["workspace_id", "id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_seen": {
|
||||
"name": "time_seen",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "enum('admin','member')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"monthly_limit": {
|
||||
"name": "monthly_limit",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"monthly_usage": {
|
||||
"name": "monthly_usage",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_monthly_usage_updated": {
|
||||
"name": "time_monthly_usage_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_account_id": {
|
||||
"name": "user_account_id",
|
||||
"columns": ["workspace_id", "account_id"],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_email": {
|
||||
"name": "user_email",
|
||||
"columns": ["workspace_id", "email"],
|
||||
"isUnique": true
|
||||
},
|
||||
"global_account_id": {
|
||||
"name": "global_account_id",
|
||||
"columns": ["account_id"],
|
||||
"isUnique": false
|
||||
},
|
||||
"global_email": {
|
||||
"name": "global_email",
|
||||
"columns": ["email"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"user_workspace_id_id_pk": {
|
||||
"name": "user_workspace_id_id_pk",
|
||||
"columns": ["workspace_id", "id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"workspace": {
|
||||
"name": "workspace",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp(3)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"columns": ["slug"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"columns": ["id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -267,6 +267,13 @@
|
||||
"when": 1761928273807,
|
||||
"tag": "0037_messy_jackal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 38,
|
||||
"version": "5",
|
||||
"when": 1764110043942,
|
||||
"tag": "0038_famous_magik",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -24,6 +24,12 @@ export namespace ZenData {
|
||||
cost: ModelCostSchema,
|
||||
cost200K: ModelCostSchema.optional(),
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
trial: z
|
||||
.object({
|
||||
limit: z.number(),
|
||||
provider: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
rateLimit: z.number().optional(),
|
||||
fallbackProvider: z.string().optional(),
|
||||
providers: z.array(
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { mysqlTable, int, primaryKey, varchar } from "drizzle-orm/mysql-core"
|
||||
import { timestamps } from "../drizzle/types"
|
||||
|
||||
export const IpTable = mysqlTable(
|
||||
"ip",
|
||||
{
|
||||
ip: varchar("ip", { length: 45 }).notNull(),
|
||||
...timestamps,
|
||||
usage: int("usage"),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.ip] })],
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLocal, type LocalFile } from "@/context/local"
|
||||
import { Collapsible } from "@/ui"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
|
||||
@@ -76,6 +76,7 @@ export default function FileTree(props: {
|
||||
<Switch>
|
||||
<Match when={node.type === "directory"}>
|
||||
<Collapsible
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
forceMount={false}
|
||||
// open={local.file.node(node.path)?.expanded}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { DateTime } from "luxon"
|
||||
@@ -16,6 +15,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
interface PromptInputProps {
|
||||
class?: string
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Part } from "@opencode-ai/sdk"
|
||||
import { produce } from "solid-js/store"
|
||||
import { createMemo } from "solid-js"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
@@ -34,29 +33,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
|
||||
|
||||
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
|
||||
const sanitize = (text: string) => text.replace(sanitizer(), "")
|
||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||
const sanitizePart = (part: Part) => {
|
||||
if (part.type === "tool") {
|
||||
if (part.state.status === "completed" || part.state.status === "error") {
|
||||
for (const key in part.state.metadata) {
|
||||
if (typeof part.state.metadata[key] === "string") {
|
||||
part.state.metadata[key] = sanitize(part.state.metadata[key] as string)
|
||||
}
|
||||
}
|
||||
for (const key in part.state.input) {
|
||||
if (typeof part.state.input[key] === "string") {
|
||||
part.state.input[key] = sanitize(part.state.input[key] as string)
|
||||
}
|
||||
}
|
||||
if ("error" in part.state) {
|
||||
part.state.error = sanitize(part.state.error as string)
|
||||
}
|
||||
}
|
||||
}
|
||||
return part
|
||||
}
|
||||
|
||||
return {
|
||||
data: store,
|
||||
@@ -88,10 +65,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
for (const message of messages.data!) {
|
||||
draft.part[message.info.id] = message.parts
|
||||
.slice()
|
||||
.map(sanitizePart)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
draft.session_diff[sessionID] = diff.data ?? []
|
||||
}),
|
||||
@@ -105,7 +79,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
},
|
||||
load,
|
||||
absolute,
|
||||
sanitize,
|
||||
get directory() {
|
||||
return store.path.directory
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function Layout(props: ParentProps) {
|
||||
{iife(() => {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<DataProvider data={sync.data}>
|
||||
<DataProvider data={sync.data} directory={directory()}>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { base64Encode, getFilename } from "@/utils"
|
||||
import { base64Encode } from "@/utils"
|
||||
import { For } from "solid-js"
|
||||
import { A } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
export default function Home() {
|
||||
const sync = useGlobalSync()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { DateTime } from "luxon"
|
||||
import { A, useParams } from "@solidjs/router"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { base64Encode, getFilename } from "@/utils"
|
||||
import { base64Encode } from "@/utils"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -11,6 +11,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const params = useParams()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js"
|
||||
import { useLocal, type LocalFile } from "@/context/local"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { DateTime } from "luxon"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
@@ -30,6 +29,7 @@ import type { JSX } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSession } from "@/context/session"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
export default function Page() {
|
||||
const layout = useLayout()
|
||||
@@ -333,7 +333,7 @@ export default function Page() {
|
||||
flex: layout.review.state() === "pane",
|
||||
}}
|
||||
>
|
||||
<div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-2xl mx-auto">
|
||||
<div class="relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-2xl mx-auto">
|
||||
<Switch>
|
||||
<Match when={session.id}>
|
||||
<div class="flex items-start justify-start h-full min-h-0">
|
||||
@@ -364,7 +364,7 @@ export default function Page() {
|
||||
<SessionTurn
|
||||
sessionID={session.id!}
|
||||
messageID={session.messages.active()?.id!}
|
||||
classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20" }}
|
||||
classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20", container: "px-6" }}
|
||||
/>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Collapsible as KobalteCollapsible } from "@kobalte/core/collapsible"
|
||||
import { Icon, IconProps } from "@opencode-ai/ui/icon"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ComponentProps, ParentProps } from "solid-js"
|
||||
|
||||
export interface CollapsibleProps extends ComponentProps<typeof KobalteCollapsible> {}
|
||||
export interface CollapsibleTriggerProps extends ComponentProps<typeof KobalteCollapsible.Trigger> {}
|
||||
export interface CollapsibleContentProps extends ComponentProps<typeof KobalteCollapsible.Content> {}
|
||||
|
||||
function CollapsibleRoot(props: CollapsibleProps) {
|
||||
return <KobalteCollapsible forceMount {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger(props: CollapsibleTriggerProps) {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<KobalteCollapsible.Trigger
|
||||
classList={{
|
||||
"w-full group/collapsible": true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent(props: ParentProps<CollapsibleContentProps>) {
|
||||
const [local, others] = splitProps(props, ["class", "children"])
|
||||
return (
|
||||
<KobalteCollapsible.Content
|
||||
classList={{
|
||||
"h-0 overflow-hidden transition-all duration-100 ease-out": true,
|
||||
"data-expanded:h-fit": true,
|
||||
[local.class]: !!local.class,
|
||||
}}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</KobalteCollapsible.Content>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleArrow(props: Partial<IconProps>) {
|
||||
const [local, others] = splitProps(props, ["class", "name"])
|
||||
return (
|
||||
<Icon
|
||||
name={local.name ?? "chevron-right"}
|
||||
classList={{
|
||||
"flex-none text-text-muted transition-transform duration-100": true,
|
||||
"group-data-[expanded]/collapsible:rotate-90": true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const Collapsible = Object.assign(CollapsibleRoot, {
|
||||
Trigger: CollapsibleTrigger,
|
||||
Content: CollapsibleContent,
|
||||
Arrow: CollapsibleArrow,
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
Collapsible,
|
||||
type CollapsibleProps,
|
||||
type CollapsibleTriggerProps,
|
||||
type CollapsibleContentProps,
|
||||
} from "./collapsible"
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./path"
|
||||
export * from "./dom"
|
||||
export * from "./encode"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
export function getFilename(path: string) {
|
||||
if (!path) return ""
|
||||
const trimmed = path.replace(/[\/]+$/, "")
|
||||
const parts = trimmed.split("/")
|
||||
return parts[parts.length - 1] ?? ""
|
||||
}
|
||||
|
||||
export function getDirectory(path: string) {
|
||||
const sync = useSync()
|
||||
const parts = path.split("/")
|
||||
const dir = parts.slice(0, parts.length - 1).join("/")
|
||||
return dir ? sync.sanitize(dir + "/") : ""
|
||||
}
|
||||
|
||||
export function getFileExtension(path: string) {
|
||||
const parts = path.split(".")
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } fro
|
||||
import { Share } from "~/core/share"
|
||||
import { Logo, Mark } from "@opencode-ai/ui/logo"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { createDefaultOptions } from "@opencode-ai/ui/pierre"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
@@ -82,20 +83,7 @@ const getData = query(async (shareID) => {
|
||||
preloadMultiFileDiff<any>({
|
||||
oldFile: { name: diff.file, contents: diff.before },
|
||||
newFile: { name: diff.file, contents: diff.after },
|
||||
options: {
|
||||
theme: "OpenCode",
|
||||
themeType: "system",
|
||||
disableLineNumbers: false,
|
||||
overflow: "wrap",
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableBackground: false,
|
||||
expansionLineCount: 20,
|
||||
lineDiffType: "none",
|
||||
maxLineDiffLength: 1000,
|
||||
maxLineLengthForHighlighting: 1000,
|
||||
disableFileHeader: true,
|
||||
},
|
||||
options: createDefaultOptions("unified"),
|
||||
// annotations,
|
||||
}),
|
||||
),
|
||||
@@ -141,219 +129,231 @@ export default function () {
|
||||
}}
|
||||
>
|
||||
<Show when={data()}>
|
||||
{(data) => (
|
||||
<DataProvider data={data()}>
|
||||
{iife(() => {
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
})
|
||||
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
|
||||
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
|
||||
const info = createMemo(() => data().session[match().index])
|
||||
const messages = createMemo(() =>
|
||||
data().sessionID
|
||||
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
|
||||
(a, b) => b.time.created - a.time.created,
|
||||
)
|
||||
: [],
|
||||
)
|
||||
const firstUserMessage = createMemo(() => messages().at(0))
|
||||
const activeMessage = createMemo(
|
||||
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
|
||||
)
|
||||
function setActiveMessage(message: UserMessage | undefined) {
|
||||
if (message) {
|
||||
setStore("messageId", message.id)
|
||||
} else {
|
||||
setStore("messageId", undefined)
|
||||
{(data) => {
|
||||
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
|
||||
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
|
||||
const info = createMemo(() => data().session[match().index])
|
||||
|
||||
return (
|
||||
<DataProvider data={data()} directory={info().directory}>
|
||||
{iife(() => {
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
})
|
||||
const messages = createMemo(() =>
|
||||
data().sessionID
|
||||
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
|
||||
(a, b) => b.time.created - a.time.created,
|
||||
)
|
||||
: [],
|
||||
)
|
||||
const firstUserMessage = createMemo(() => messages().at(0))
|
||||
const activeMessage = createMemo(
|
||||
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
|
||||
)
|
||||
function setActiveMessage(message: UserMessage | undefined) {
|
||||
if (message) {
|
||||
setStore("messageId", message.id)
|
||||
} else {
|
||||
setStore("messageId", undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
const provider = createMemo(() => activeMessage()?.model?.providerID)
|
||||
const modelID = createMemo(() => activeMessage()?.model?.modelID)
|
||||
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
|
||||
const diffs = createMemo(() => {
|
||||
const diffs = data().session_diff[data().sessionID] ?? []
|
||||
const preloaded = data().session_diff_preload[data().sessionID] ?? []
|
||||
return diffs.map((diff) => ({
|
||||
...diff,
|
||||
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
|
||||
}))
|
||||
})
|
||||
const provider = createMemo(() => activeMessage()?.model?.providerID)
|
||||
const modelID = createMemo(() => activeMessage()?.model?.modelID)
|
||||
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
|
||||
const diffs = createMemo(() => {
|
||||
const diffs = data().session_diff[data().sessionID] ?? []
|
||||
const preloaded = data().session_diff_preload[data().sessionID] ?? []
|
||||
return diffs.map((diff) => ({
|
||||
...diff,
|
||||
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
|
||||
}))
|
||||
})
|
||||
|
||||
const title = () => (
|
||||
<div class="flex flex-col gap-4 shrink-0">
|
||||
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
|
||||
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
|
||||
<Mark class="shrink-0 w-3 my-0.5" />
|
||||
<div class="text-12-mono text-text-base">v{info().version}</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<img src={`https://models.dev/logos/${provider()}.svg`} class="size-3.5 shrink-0 dark:invert" />
|
||||
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weaker">
|
||||
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
|
||||
const title = () => (
|
||||
<div class="flex flex-col gap-4 shrink-0">
|
||||
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
|
||||
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
|
||||
<Mark class="shrink-0 w-3 my-0.5" />
|
||||
<div class="text-12-mono text-text-base">v{info().version}</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<img src={`https://models.dev/logos/${provider()}.svg`} class="size-3.5 shrink-0 dark:invert" />
|
||||
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weaker">
|
||||
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
|
||||
</div>
|
||||
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
const turns = () => (
|
||||
<div class="relative mt-2 pt-6 pb-8 px-4 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
|
||||
{title()}
|
||||
<div class="flex flex-col gap-15 items-start justify-start mt-4">
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<SessionTurn
|
||||
sessionID={data().sessionID}
|
||||
messageID={message.id}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content:
|
||||
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="flex items-center justify-center pt-20 pb-8 shrink-0">
|
||||
<Logo class="w-58.5 opacity-12" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const wide = createMemo(() => diffs().length === 0)
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
|
||||
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
|
||||
<div class="">
|
||||
<a href="https://opencode.ai">
|
||||
<Mark />
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex gap-3 items-center">
|
||||
<IconButton
|
||||
as={"a"}
|
||||
href="https://github.com/sst/opencode"
|
||||
target="_blank"
|
||||
icon="github"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
as={"a"}
|
||||
href="https://opencode.ai/discord"
|
||||
target="_blank"
|
||||
icon="discord"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div class="select-text flex flex-col flex-1 min-h-0">
|
||||
<div class="hidden md:flex w-full flex-1 min-h-0">
|
||||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true,
|
||||
"px-21 @4xl:px-6 max-w-2xl": !wide(),
|
||||
"px-6 max-w-2xl": wide(),
|
||||
}}
|
||||
>
|
||||
{title()}
|
||||
<div class="flex items-start justify-start h-full min-h-0">
|
||||
<Show when={messages().length > 1}>
|
||||
<>
|
||||
<div class="md:hidden absolute right-full">
|
||||
<MessageNav
|
||||
class="mt-2 mr-3"
|
||||
messages={messages()}
|
||||
current={activeMessage()}
|
||||
onMessageSelect={setActiveMessage}
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"hidden md:block": true,
|
||||
"absolute right-[90%]": !wide(),
|
||||
"absolute right-full": wide(),
|
||||
}}
|
||||
>
|
||||
<MessageNav
|
||||
classList={{
|
||||
"mt-2.5 mr-3": !wide(),
|
||||
"mt-0.5 mr-8": wide(),
|
||||
}}
|
||||
messages={messages()}
|
||||
current={activeMessage()}
|
||||
onMessageSelect={setActiveMessage}
|
||||
size={wide() ? "normal" : "compact"}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</Show>
|
||||
const turns = () => (
|
||||
<div class="relative mt-2 pt-6 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
|
||||
<div class="px-4">{title()}</div>
|
||||
<div class="flex flex-col gap-15 items-start justify-start mt-4">
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<SessionTurn
|
||||
sessionID={data().sessionID}
|
||||
messageID={store.messageId ?? firstUserMessage()!.id!}
|
||||
classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
|
||||
>
|
||||
<div class="flex items-center justify-center pb-8 shrink-0">
|
||||
<Logo class="w-58.5 opacity-12" />
|
||||
</div>
|
||||
</SessionTurn>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={diffs().length > 0}>
|
||||
<div class="relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
|
||||
<SessionReview
|
||||
diffs={diffs()}
|
||||
messageID={message.id}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
container: "px-6",
|
||||
root: "min-w-0 w-full relative",
|
||||
content:
|
||||
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
|
||||
<Logo class="w-58.5 opacity-12" />
|
||||
</div>
|
||||
<Switch>
|
||||
<Match when={diffs().length > 0}>
|
||||
<Tabs class="md:hidden">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
|
||||
Session
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
|
||||
5 Files Changed
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="session" class="!overflow-hidden">
|
||||
{turns()}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content forceMount value="review" class="!overflow-hidden hidden data-[selected]:block">
|
||||
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
|
||||
<SessionReview
|
||||
diffs={diffs()}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="md:hidden !overflow-hidden">{turns()}</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</DataProvider>
|
||||
)}
|
||||
)
|
||||
|
||||
const wide = createMemo(() => diffs().length === 0)
|
||||
const columnPadding = () => (wide() ? "px-6" : "px-21 @4xl:px-6")
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
|
||||
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
|
||||
<div class="">
|
||||
<a href="https://opencode.ai">
|
||||
<Mark />
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex gap-3 items-center">
|
||||
<IconButton
|
||||
as={"a"}
|
||||
href="https://github.com/sst/opencode"
|
||||
target="_blank"
|
||||
icon="github"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
as={"a"}
|
||||
href="https://opencode.ai/discord"
|
||||
target="_blank"
|
||||
icon="discord"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div class="select-text flex flex-col flex-1 min-h-0">
|
||||
<div class="hidden md:flex w-full flex-1 min-h-0">
|
||||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true,
|
||||
"max-w-2xl": true,
|
||||
}}
|
||||
>
|
||||
<div class={columnPadding()}>{title()}</div>
|
||||
<div class="flex items-start justify-start h-full min-h-0">
|
||||
<Show when={messages().length > 1}>
|
||||
<>
|
||||
<div class="md:hidden absolute right-full">
|
||||
<MessageNav
|
||||
class="mt-2 mr-3"
|
||||
messages={messages()}
|
||||
current={activeMessage()}
|
||||
onMessageSelect={setActiveMessage}
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"hidden md:block": true,
|
||||
"absolute right-[90%]": !wide(),
|
||||
"absolute right-full": wide(),
|
||||
}}
|
||||
>
|
||||
<MessageNav
|
||||
classList={{
|
||||
"mt-2.5 mr-3": !wide(),
|
||||
"mt-0.5 mr-8": wide(),
|
||||
}}
|
||||
messages={messages()}
|
||||
current={activeMessage()}
|
||||
onMessageSelect={setActiveMessage}
|
||||
size={wide() ? "normal" : "compact"}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={data().sessionID}
|
||||
messageID={store.messageId ?? firstUserMessage()!.id!}
|
||||
classes={{
|
||||
root: "grow",
|
||||
content: "flex flex-col justify-between",
|
||||
container: `${columnPadding()} pb-20`,
|
||||
}}
|
||||
>
|
||||
<div class={`${columnPadding()} flex items-center justify-center pb-8 shrink-0`}>
|
||||
<Logo class="w-58.5 opacity-12" />
|
||||
</div>
|
||||
</SessionTurn>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={diffs().length > 0}>
|
||||
<div class="relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
|
||||
<SessionReview
|
||||
diffs={diffs()}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
container: "px-6",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Switch>
|
||||
<Match when={diffs().length > 0}>
|
||||
<Tabs class="md:hidden">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
|
||||
Session
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
|
||||
5 Files Changed
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="session" class="!overflow-hidden">
|
||||
{turns()}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
forceMount
|
||||
value="review"
|
||||
class="!overflow-hidden hidden data-[selected]:block"
|
||||
>
|
||||
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
|
||||
<SessionReview
|
||||
diffs={diffs()}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="md:hidden !overflow-hidden">{turns()}</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</DataProvider>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The AI coding agent built for the terminal"
|
||||
version = "1.0.112"
|
||||
version = "1.0.116"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/sst/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.112/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.116/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.112/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.116/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.112/opencode-linux-arm64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.116/opencode-linux-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.112/opencode-linux-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.116/opencode-linux-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.112/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.116/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { graphql } from "@octokit/graphql"
|
||||
import * as core from "@actions/core"
|
||||
import * as github from "@actions/github"
|
||||
import type { Context } from "@actions/github/lib/context"
|
||||
import type { IssueCommentEvent } from "@octokit/webhooks-types"
|
||||
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
@@ -328,6 +328,8 @@ export const GithubInstallCommand = cmd({
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
opencode:
|
||||
@@ -378,7 +380,7 @@ export const GithubRunCommand = cmd({
|
||||
const isMock = args.token || args.event
|
||||
|
||||
const context = isMock ? (JSON.parse(args.event!) as Context) : github.context
|
||||
if (context.eventName !== "issue_comment") {
|
||||
if (context.eventName !== "issue_comment" && context.eventName !== "pull_request_review_comment") {
|
||||
core.setFailed(`Unsupported event type: ${context.eventName}`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -387,9 +389,14 @@ export const GithubRunCommand = cmd({
|
||||
const runId = normalizeRunId()
|
||||
const share = normalizeShare()
|
||||
const { owner, repo } = context.repo
|
||||
const payload = context.payload as IssueCommentEvent
|
||||
const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
|
||||
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
|
||||
const actor = context.actor
|
||||
const issueId = payload.issue.number
|
||||
|
||||
const issueId =
|
||||
context.eventName === "pull_request_review_comment"
|
||||
? (payload as PullRequestReviewCommentEvent).pull_request.number
|
||||
: (payload as IssueCommentEvent).issue.number
|
||||
const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
|
||||
const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai"
|
||||
|
||||
@@ -434,7 +441,7 @@ export const GithubRunCommand = cmd({
|
||||
// 1. Issue
|
||||
// 2. Local PR
|
||||
// 3. Fork PR
|
||||
if (payload.issue.pull_request) {
|
||||
if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
|
||||
const prData = await fetchPR()
|
||||
// Local PR
|
||||
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
|
||||
@@ -531,11 +538,45 @@ export const GithubRunCommand = cmd({
|
||||
throw new Error(`Invalid share value: ${value}. Share must be a boolean.`)
|
||||
}
|
||||
|
||||
function isIssueCommentEvent(
|
||||
event: IssueCommentEvent | PullRequestReviewCommentEvent,
|
||||
): event is IssueCommentEvent {
|
||||
return "issue" in event
|
||||
}
|
||||
|
||||
function getReviewCommentContext() {
|
||||
if (context.eventName !== "pull_request_review_comment") {
|
||||
return null
|
||||
}
|
||||
|
||||
const reviewPayload = payload as PullRequestReviewCommentEvent
|
||||
return {
|
||||
file: reviewPayload.comment.path,
|
||||
diffHunk: reviewPayload.comment.diff_hunk,
|
||||
line: reviewPayload.comment.line,
|
||||
originalLine: reviewPayload.comment.original_line,
|
||||
position: reviewPayload.comment.position,
|
||||
commitId: reviewPayload.comment.commit_id,
|
||||
originalCommitId: reviewPayload.comment.original_commit_id,
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserPrompt() {
|
||||
const reviewContext = getReviewCommentContext()
|
||||
let prompt = (() => {
|
||||
const body = payload.comment.body.trim()
|
||||
if (body === "/opencode" || body === "/oc") return "Summarize this thread"
|
||||
if (body.includes("/opencode") || body.includes("/oc")) return body
|
||||
if (body === "/opencode" || body === "/oc") {
|
||||
if (reviewContext) {
|
||||
return `Review this code change and suggest improvements for the commented lines:\n\nFile: ${reviewContext.file}\nLines: ${reviewContext.line}\n\n${reviewContext.diffHunk}`
|
||||
}
|
||||
return "Summarize this thread"
|
||||
}
|
||||
if (body.includes("/opencode") || body.includes("/oc")) {
|
||||
if (reviewContext) {
|
||||
return `${body}\n\nContext: You are reviewing a comment on file "${reviewContext.file}" at line ${reviewContext.line}.\n\nDiff context:\n${reviewContext.diffHunk}`
|
||||
}
|
||||
return body
|
||||
}
|
||||
throw new Error("Comments must mention `/opencode` or `/oc`")
|
||||
})()
|
||||
|
||||
@@ -652,7 +693,10 @@ export const GithubRunCommand = cmd({
|
||||
try {
|
||||
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
||||
} catch (e) {
|
||||
return `Fix issue: ${payload.issue.title}`
|
||||
const title = issueEvent
|
||||
? issueEvent.issue.title
|
||||
: (payload as PullRequestReviewCommentEvent).pull_request.title
|
||||
return `Fix issue: ${title}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,16 +186,13 @@ function App() {
|
||||
})
|
||||
})
|
||||
|
||||
let continued = false
|
||||
createEffect(() => {
|
||||
if (sync.status !== "complete") return
|
||||
if (args.continue) {
|
||||
const match = sync.data.session.at(0)?.id
|
||||
if (match) {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: match,
|
||||
})
|
||||
}
|
||||
if (continued || sync.status !== "complete" || !args.continue) return
|
||||
const match = sync.data.session.at(0)?.id
|
||||
if (match) {
|
||||
continued = true
|
||||
route.navigate({ type: "session", sessionID: match })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -455,48 +452,14 @@ function App() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<box
|
||||
height={1}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box flexDirection="row" backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.textMuted}>open</text>
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
code{" "}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
|
||||
</box>
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
|
||||
</box>
|
||||
</box>
|
||||
<Show when={false}>
|
||||
<box flexDirection="row" flexShrink={0}>
|
||||
<text fg={theme.textMuted} paddingRight={1}>
|
||||
tab
|
||||
</text>
|
||||
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
|
||||
<text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}>
|
||||
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
|
||||
<span> AGENT </span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy, take } from "remeda"
|
||||
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
|
||||
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
|
||||
export function DialogModel() {
|
||||
const local = useLocal()
|
||||
@@ -16,14 +17,45 @@ export function DialogModel() {
|
||||
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
|
||||
)
|
||||
|
||||
const showRecent = createMemo(() => !ref()?.filter && local.model.recent().length > 0 && connected())
|
||||
const providers = createDialogProviderOptions()
|
||||
|
||||
const options = createMemo(() => {
|
||||
return [
|
||||
...(showRecent()
|
||||
? local.model.recent().flatMap((item) => {
|
||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)!
|
||||
const query = ref()?.filter
|
||||
const favorites = local.model.favorite()
|
||||
const recents = local.model.recent()
|
||||
const currentModel = local.model.current()
|
||||
|
||||
const orderedRecents = currentModel
|
||||
? [
|
||||
currentModel,
|
||||
...recents.filter(
|
||||
(item) => item.providerID !== currentModel.providerID || item.modelID !== currentModel.modelID,
|
||||
),
|
||||
]
|
||||
: recents
|
||||
|
||||
const isCurrent = (item: { providerID: string; modelID: string }) =>
|
||||
currentModel && item.providerID === currentModel.providerID && item.modelID === currentModel.modelID
|
||||
|
||||
const currentIsFavorite = currentModel && favorites.some((fav) => isCurrent(fav))
|
||||
|
||||
const recentList = orderedRecents
|
||||
.filter((item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID))
|
||||
.slice(0, 5)
|
||||
|
||||
const orderedFavorites = currentModel
|
||||
? [...favorites.filter((item) => isCurrent(item)), ...favorites.filter((item) => !isCurrent(item))]
|
||||
: favorites
|
||||
|
||||
const orderedRecentList =
|
||||
currentModel && !currentIsFavorite
|
||||
? [...recentList.filter((item) => isCurrent(item)), ...recentList.filter((item) => !isCurrent(item))]
|
||||
: recentList
|
||||
|
||||
const favoriteOptions =
|
||||
!query && favorites.length > 0
|
||||
? orderedFavorites.flatMap((item) => {
|
||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||
if (!provider) return []
|
||||
const model = provider.models[item.modelID]
|
||||
if (!model) return []
|
||||
@@ -35,8 +67,9 @@ export function DialogModel() {
|
||||
modelID: model.id,
|
||||
},
|
||||
title: model.name ?? item.modelID,
|
||||
description: provider.name,
|
||||
category: "Recent",
|
||||
description: `${provider.name} ★`,
|
||||
category: "Favorites",
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
@@ -51,7 +84,44 @@ export function DialogModel() {
|
||||
},
|
||||
]
|
||||
})
|
||||
: []),
|
||||
: []
|
||||
|
||||
const recentOptions = !query
|
||||
? orderedRecentList.flatMap((item) => {
|
||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||
if (!provider) return []
|
||||
const model = provider.models[item.modelID]
|
||||
if (!model) return []
|
||||
return [
|
||||
{
|
||||
key: item,
|
||||
value: {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
title: model.name ?? item.modelID,
|
||||
description: provider.name,
|
||||
category: "Recent",
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
: []
|
||||
|
||||
return [
|
||||
...favoriteOptions,
|
||||
...recentOptions,
|
||||
...pipe(
|
||||
sync.data.provider,
|
||||
sortBy(
|
||||
@@ -62,28 +132,46 @@ export function DialogModel() {
|
||||
pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
map(([model, info]) => ({
|
||||
value: {
|
||||
map(([model, info]) => {
|
||||
const value = {
|
||||
providerID: provider.id,
|
||||
modelID: model,
|
||||
},
|
||||
title: info.name ?? model,
|
||||
description: connected() ? provider.name : undefined,
|
||||
category: connected() ? provider.name : undefined,
|
||||
disabled: provider.id === "opencode" && model.includes("-nano"),
|
||||
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect() {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
providerID: provider.id,
|
||||
modelID: model,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
})),
|
||||
filter((x) => !showRecent() || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
|
||||
}
|
||||
const favorite = favorites.some(
|
||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||
)
|
||||
return {
|
||||
value,
|
||||
title: info.name ?? model,
|
||||
description: connected() ? `${provider.name}${favorite ? " ★" : ""}` : undefined,
|
||||
category: connected() ? provider.name : undefined,
|
||||
disabled: provider.id === "opencode" && model.includes("-nano"),
|
||||
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect() {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
providerID: provider.id,
|
||||
modelID: model,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
}
|
||||
}),
|
||||
filter((x) => {
|
||||
if (query) return true
|
||||
const value = x.value
|
||||
const inFavorites = favorites.some(
|
||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||
)
|
||||
const inRecents = orderedRecents.some(
|
||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||
)
|
||||
if (inFavorites) return false
|
||||
if (inRecents) return false
|
||||
return true
|
||||
}),
|
||||
sortBy((x) => x.title),
|
||||
),
|
||||
),
|
||||
@@ -113,6 +201,13 @@ export function DialogModel() {
|
||||
dialog.replace(() => <DialogProvider />)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: Keybind.parse("ctrl+f")[0],
|
||||
title: "Favorite",
|
||||
onTrigger: (option) => {
|
||||
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
|
||||
},
|
||||
},
|
||||
]}
|
||||
ref={setRef}
|
||||
title="Select model"
|
||||
|
||||
@@ -197,11 +197,24 @@ function ApiMethod(props: ApiMethodProps) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title={props.title}
|
||||
placeholder="API key"
|
||||
description={
|
||||
props.providerID === "opencode" ? (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API key.
|
||||
</text>
|
||||
<text>
|
||||
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key
|
||||
</text>
|
||||
</box>
|
||||
) : undefined
|
||||
}
|
||||
onConfirm={async (value) => {
|
||||
if (!value) return
|
||||
sdk.client.auth.set({
|
||||
|
||||
@@ -81,6 +81,7 @@ export function Autocomplete(props: {
|
||||
const extmarkId = input.extmarks.create({
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
virtual: true,
|
||||
styleId,
|
||||
typeId: props.promptPartTypeId(),
|
||||
})
|
||||
@@ -291,6 +292,11 @@ export function Autocomplete(props: {
|
||||
description: "open editor",
|
||||
onSelect: () => command.trigger("prompt.editor", "prompt"),
|
||||
},
|
||||
{
|
||||
display: "/connect",
|
||||
description: "connect to a provider",
|
||||
onSelect: () => command.trigger("provider.connect"),
|
||||
},
|
||||
{
|
||||
display: "/help",
|
||||
description: "show help",
|
||||
|
||||
@@ -310,6 +310,7 @@ export function Prompt(props: PromptProps) {
|
||||
const extmarkId = input.extmarks.create({
|
||||
start,
|
||||
end,
|
||||
virtual: true,
|
||||
styleId,
|
||||
typeId: promptPartTypeId,
|
||||
})
|
||||
@@ -496,6 +497,40 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
const exit = useExit()
|
||||
|
||||
function pasteText(text: string, virtualText: string) {
|
||||
const currentOffset = input.visualCursor.offset
|
||||
const extmarkStart = currentOffset
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
|
||||
input.insertText(virtualText + " ")
|
||||
|
||||
const extmarkId = input.extmarks.create({
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
virtual: true,
|
||||
styleId: pasteStyleId,
|
||||
typeId: promptPartTypeId,
|
||||
})
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const partIndex = draft.prompt.parts.length
|
||||
draft.prompt.parts.push({
|
||||
type: "text" as const,
|
||||
text,
|
||||
source: {
|
||||
text: {
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
value: virtualText,
|
||||
},
|
||||
},
|
||||
})
|
||||
draft.extmarkToPartIndex.set(extmarkId, partIndex)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function pasteImage(file: { filename?: string; content: string; mime: string }) {
|
||||
const currentOffset = input.visualCursor.offset
|
||||
const extmarkStart = currentOffset
|
||||
@@ -602,11 +637,7 @@ export function Prompt(props: PromptProps) {
|
||||
flexGrow={1}
|
||||
>
|
||||
<textarea
|
||||
placeholder={
|
||||
props.showPlaceholder
|
||||
? t`${dim(fg(theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
|
||||
: undefined
|
||||
}
|
||||
placeholder={props.sessionID ? undefined : "Build anything..."}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
minHeight={1}
|
||||
@@ -705,25 +736,36 @@ export function Prompt(props: PromptProps) {
|
||||
// trim ' from the beginning and end of the pasted content. just
|
||||
// ' and nothing else
|
||||
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
|
||||
console.log(pastedContent, filepath)
|
||||
try {
|
||||
const file = Bun.file(filepath)
|
||||
if (file.type.startsWith("image/")) {
|
||||
event.preventDefault()
|
||||
const content = await file
|
||||
.arrayBuffer()
|
||||
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
||||
.catch(console.error)
|
||||
if (content) {
|
||||
await pasteImage({
|
||||
filename: file.name,
|
||||
mime: file.type,
|
||||
content,
|
||||
})
|
||||
return
|
||||
const isUrl = /^(https?):\/\//.test(filepath)
|
||||
if (!isUrl) {
|
||||
try {
|
||||
const file = Bun.file(filepath)
|
||||
// Handle SVG as raw text content, not as base64 image
|
||||
if (file.type === "image/svg+xml") {
|
||||
event.preventDefault()
|
||||
const content = await file.text().catch(() => {})
|
||||
if (content) {
|
||||
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
if (file.type.startsWith("image/")) {
|
||||
event.preventDefault()
|
||||
const content = await file
|
||||
.arrayBuffer()
|
||||
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
||||
.catch(() => {})
|
||||
if (content) {
|
||||
await pasteImage({
|
||||
filename: file.name,
|
||||
mime: file.type,
|
||||
content,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if (
|
||||
@@ -731,45 +773,16 @@ export function Prompt(props: PromptProps) {
|
||||
!sync.data.config.experimental?.disable_paste_summary
|
||||
) {
|
||||
event.preventDefault()
|
||||
const currentOffset = input.visualCursor.offset
|
||||
const virtualText = `[Pasted ~${lineCount} lines]`
|
||||
const textToInsert = virtualText + " "
|
||||
const extmarkStart = currentOffset
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
|
||||
input.insertText(textToInsert)
|
||||
|
||||
const extmarkId = input.extmarks.create({
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
virtual: true,
|
||||
styleId: pasteStyleId,
|
||||
typeId: promptPartTypeId,
|
||||
})
|
||||
|
||||
const part = {
|
||||
type: "text" as const,
|
||||
text: pastedContent,
|
||||
source: {
|
||||
text: {
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
value: virtualText,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const partIndex = draft.prompt.parts.length
|
||||
draft.prompt.parts.push(part)
|
||||
draft.extmarkToPartIndex.set(extmarkId, partIndex)
|
||||
}),
|
||||
)
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
}
|
||||
}}
|
||||
ref={(r: TextareaRenderable) => (input = r)}
|
||||
ref={(r: TextareaRenderable) => {
|
||||
input = r
|
||||
setTimeout(() => {
|
||||
input.cursorColor = highlight()
|
||||
}, 0)
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||
focusedBackgroundColor={theme.backgroundElement}
|
||||
cursorColor={highlight()}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSync } from "./sync"
|
||||
import { Global } from "@/global"
|
||||
|
||||
export function useDirectory() {
|
||||
const sync = useSync()
|
||||
return createMemo(() => {
|
||||
const result = process.cwd().replace(Global.Path.home, "~")
|
||||
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
|
||||
return result
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { FormatError } from "@/cli/error"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
|
||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
name: "Exit",
|
||||
@@ -10,8 +10,10 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
renderer.destroy()
|
||||
await input.onExit?.()
|
||||
if (reason) {
|
||||
const formatted = FormatError(reason) ?? JSON.stringify(reason)
|
||||
process.stderr.write(formatted + "\n")
|
||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||
if (formatted) {
|
||||
process.stderr.write(formatted + "\n")
|
||||
}
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@@ -114,18 +114,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
favorite: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
}>({
|
||||
ready: false,
|
||||
model: {},
|
||||
recent: [],
|
||||
favorite: [],
|
||||
})
|
||||
|
||||
const file = Bun.file(path.join(Global.Path.state, "model.json"))
|
||||
|
||||
function save() {
|
||||
Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
recent: modelStore.recent,
|
||||
favorite: modelStore.favorite,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
file
|
||||
.json()
|
||||
.then((x) => {
|
||||
setModelStore("recent", x.recent)
|
||||
if (Array.isArray(x.recent)) setModelStore("recent", x.recent)
|
||||
if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
@@ -184,6 +200,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
recent() {
|
||||
return modelStore.recent
|
||||
},
|
||||
favorite() {
|
||||
return modelStore.favorite
|
||||
},
|
||||
parsed: createMemo(() => {
|
||||
const value = currentModel()
|
||||
const provider = sync.data.provider.find((x) => x.id === value.providerID)!
|
||||
@@ -206,6 +225,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
if (!val) return
|
||||
setModelStore("model", agent.current().name, { ...val })
|
||||
},
|
||||
cycleFavorite(direction: 1 | -1) {
|
||||
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
|
||||
if (!favorites.length) {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "Add a favorite model to use this shortcut",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const current = currentModel()
|
||||
let index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
||||
if (index === -1) {
|
||||
index = direction === 1 ? 0 : favorites.length - 1
|
||||
} else {
|
||||
index += direction
|
||||
if (index < 0) index = favorites.length - 1
|
||||
if (index >= favorites.length) index = 0
|
||||
}
|
||||
const next = favorites[index]
|
||||
if (!next) return
|
||||
setModelStore("model", agent.current().name, { ...next })
|
||||
const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 10) uniq.pop()
|
||||
setModelStore("recent", uniq)
|
||||
save()
|
||||
},
|
||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
if (!isModelValid(model)) {
|
||||
@@ -219,17 +265,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
setModelStore("model", agent.current().name, model)
|
||||
if (options?.recent) {
|
||||
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
if (uniq.length > 10) uniq.pop()
|
||||
setModelStore("recent", uniq)
|
||||
Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
recent: modelStore.recent,
|
||||
}),
|
||||
)
|
||||
save()
|
||||
}
|
||||
})
|
||||
},
|
||||
toggleFavorite(model: { providerID: string; modelID: string }) {
|
||||
batch(() => {
|
||||
if (!isModelValid(model)) {
|
||||
toast.show({
|
||||
message: `Model ${model.providerID}/${model.modelID} is not valid`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const exists = modelStore.favorite.some(
|
||||
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
|
||||
)
|
||||
const next = exists
|
||||
? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
|
||||
: [model, ...modelStore.favorite]
|
||||
setModelStore("favorite", next)
|
||||
save()
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup } from "solid-js"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
@@ -16,43 +17,49 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}>()
|
||||
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
let queue: Event[] = []
|
||||
let timer: Timer | undefined
|
||||
let last = 0
|
||||
|
||||
const flush = () => {
|
||||
if (queue.length === 0) return
|
||||
const events = queue
|
||||
queue = []
|
||||
timer = undefined
|
||||
last = Date.now()
|
||||
// Batch all event emissions so all store updates result in a single render
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
emitter.emit(event.type, event)
|
||||
}
|
||||
onMount(async () => {
|
||||
while (true) {
|
||||
if (abort.signal.aborted) break
|
||||
const events = await sdk.event.subscribe({
|
||||
signal: abort.signal,
|
||||
})
|
||||
}
|
||||
let queue: Event[] = []
|
||||
let timer: Timer | undefined
|
||||
let last = 0
|
||||
|
||||
for await (const event of events.stream) {
|
||||
queue.push(event)
|
||||
const elapsed = Date.now() - last
|
||||
|
||||
if (timer) continue
|
||||
// If we just flushed recently (within 16ms), batch this with future events
|
||||
// Otherwise, process immediately to avoid latency
|
||||
if (elapsed < 16) {
|
||||
timer = setTimeout(flush, 16)
|
||||
continue
|
||||
const flush = () => {
|
||||
if (queue.length === 0) return
|
||||
const events = queue
|
||||
queue = []
|
||||
timer = undefined
|
||||
last = Date.now()
|
||||
// Batch all event emissions so all store updates result in a single render
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
emitter.emit(event.type, event)
|
||||
}
|
||||
})
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
||||
// Flush any remaining events
|
||||
if (timer) clearTimeout(timer)
|
||||
if (queue.length > 0) {
|
||||
flush()
|
||||
for await (const event of events.stream) {
|
||||
queue.push(event)
|
||||
const elapsed = Date.now() - last
|
||||
|
||||
if (timer) continue
|
||||
// If we just flushed recently (within 16ms), batch this with future events
|
||||
// Otherwise, process immediately to avoid latency
|
||||
if (elapsed < 16) {
|
||||
timer = setTimeout(flush, 16)
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
||||
// Flush any remaining events
|
||||
if (timer) clearTimeout(timer)
|
||||
if (queue.length > 0) {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
SessionStatus,
|
||||
ProviderListResponse,
|
||||
ProviderAuthMethod,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
@@ -22,6 +23,7 @@ import { createSimpleContext } from "./helper"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
@@ -59,6 +61,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
[key: string]: McpStatus
|
||||
}
|
||||
formatter: FormatterStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
}>({
|
||||
provider_next: {
|
||||
all: [],
|
||||
@@ -82,6 +85,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
lsp: [],
|
||||
mcp: {},
|
||||
formatter: [],
|
||||
vcs: undefined,
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
@@ -238,6 +242,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
|
||||
break
|
||||
}
|
||||
|
||||
case "vcs.branch.updated": {
|
||||
setStore("vcs", { branch: event.properties.branch })
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -276,11 +285,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
|
||||
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
|
||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
|
||||
sdk.client.vcs.get().then((x) => setStore("vcs", x.data)),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
})
|
||||
.catch(async (e) => {
|
||||
Log.Default.error("tui bootstrap failed", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
name: e instanceof Error ? e.name : undefined,
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
})
|
||||
await exit(e)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Locale } from "@/util/locale"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useArgs } from "../context/args"
|
||||
import { Global } from "@/global"
|
||||
import { useDirectory } from "../context/directory"
|
||||
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
@@ -15,6 +17,7 @@ let once = false
|
||||
export function Home() {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
|
||||
const mcpError = createMemo(() => {
|
||||
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
||||
})
|
||||
@@ -47,31 +50,36 @@ export function Home() {
|
||||
once = true
|
||||
}
|
||||
})
|
||||
const directory = useDirectory()
|
||||
|
||||
return (
|
||||
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<Logo />
|
||||
<box width={39}>
|
||||
<HelpRow keybind="command_list">Commands</HelpRow>
|
||||
<HelpRow keybind="session_list">List sessions</HelpRow>
|
||||
<HelpRow keybind="model_list">Switch model</HelpRow>
|
||||
<HelpRow keybind="agent_cycle">Switch agent</HelpRow>
|
||||
<>
|
||||
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<Logo />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
|
||||
<Prompt ref={(r) => (prompt = r)} hint={Hint} />
|
||||
</box>
|
||||
<Toast />
|
||||
</box>
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
|
||||
<Prompt ref={(r) => (prompt = r)} hint={Hint} />
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<Show when={mcp()}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: theme.success }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{Object.keys(sync.data.mcp).length} MCP
|
||||
</text>
|
||||
<text fg={theme.textMuted}>/status</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
<Toast />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="space-between" width="100%">
|
||||
<text fg={theme.text}>{props.children}</text>
|
||||
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
|
||||
export function Footer() {
|
||||
const { theme } = useTheme()
|
||||
const sync = useSync()
|
||||
const mcp = createMemo(() => Object.keys(sync.data.mcp))
|
||||
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
|
||||
const lsp = createMemo(() => Object.keys(sync.data.lsp))
|
||||
const directory = useDirectory()
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
<box gap={2} flexDirection="row" flexShrink={0}>
|
||||
<text fg={theme.text}>
|
||||
<span style={{ fg: theme.success }}>•</span> {lsp().length} LSP
|
||||
</text>
|
||||
<Show when={mcp().length}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: theme.success }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{mcp().length} MCP
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>/status</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -3,15 +3,16 @@ 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 { SplitBorder, EmptyBorder } from "@tui/component/border"
|
||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
|
||||
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>
|
||||
<span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
@@ -53,43 +54,71 @@ export function Header() {
|
||||
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||
let result = total.toLocaleString()
|
||||
if (model?.limit.context) {
|
||||
result += "/" + Math.round((total / model.limit.context) * 100) + "%"
|
||||
result += " " + Math.round((total / model.limit.context) * 100) + "%"
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
|
||||
return (
|
||||
<box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={theme.backgroundElement} flexShrink={0}>
|
||||
<Show
|
||||
when={shareEnabled()}
|
||||
fallback={
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<Title session={session} />
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
}
|
||||
<box flexShrink={0}>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={theme.border}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
>
|
||||
<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>
|
||||
<Switch>
|
||||
<Match when={session()?.parentID}>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
|
||||
</text>
|
||||
<box flexGrow={1} flexShrink={1} />
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!shareEnabled()}>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<Title session={session} />
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<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>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import { Toast, useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv.tsx"
|
||||
import { Editor } from "../../util/editor"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { Footer } from "./footer.tsx"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -80,6 +81,7 @@ const context = createContext<{
|
||||
conceal: () => boolean
|
||||
showThinking: () => boolean
|
||||
showTimestamps: () => boolean
|
||||
sync: ReturnType<typeof useSync>
|
||||
}>()
|
||||
|
||||
function use() {
|
||||
@@ -113,7 +115,12 @@ export function Session() {
|
||||
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
|
||||
|
||||
const wide = createMemo(() => dimensions().width > 120)
|
||||
const sidebarVisible = createMemo(() => sidebar() === "show" || (sidebar() === "auto" && wide()))
|
||||
const sidebarVisible = createMemo(() => {
|
||||
if (session()?.parentID) return false
|
||||
if (sidebar() === "show") return true
|
||||
if (sidebar() === "auto" && wide()) return true
|
||||
return false
|
||||
})
|
||||
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
||||
|
||||
const scrollAcceleration = createMemo(() => {
|
||||
@@ -319,7 +326,9 @@ export function Session() {
|
||||
value: "session.undo",
|
||||
keybind: "messages_undo",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
onSelect: async (dialog) => {
|
||||
const status = sync.data.session_status[route.sessionID]
|
||||
if (status?.type !== "idle") await sdk.client.session.abort({ path: { id: route.sessionID } }).catch(() => {})
|
||||
const revert = session().revert?.messageID
|
||||
const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
|
||||
if (!message) return
|
||||
@@ -730,33 +739,12 @@ export function Session() {
|
||||
conceal,
|
||||
showThinking,
|
||||
showTimestamps,
|
||||
sync,
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={2}>
|
||||
<box flexGrow={1} gap={1}>
|
||||
<box flexDirection="row">
|
||||
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<Show when={session()}>
|
||||
<Show when={session().parentID}>
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
justifyContent="space-between"
|
||||
flexDirection="row"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
flexShrink={0}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Previous <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
<b>Viewing subagent session</b>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
<span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span> Next
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!sidebarVisible()}>
|
||||
<Header />
|
||||
</Show>
|
||||
@@ -881,6 +869,9 @@ export function Session() {
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</box>
|
||||
<Show when={!sidebarVisible()}>
|
||||
<Footer />
|
||||
</Show>
|
||||
</Show>
|
||||
<Toast />
|
||||
</box>
|
||||
@@ -1445,6 +1436,34 @@ ToolRegistry.register<typeof WebFetchTool>({
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "codesearch",
|
||||
container: "inline",
|
||||
render(props: ToolProps<any>) {
|
||||
const input = props.input as any
|
||||
const metadata = props.metadata as any
|
||||
return (
|
||||
<ToolTitle icon="◇" fallback="Searching code..." when={input.query}>
|
||||
Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
|
||||
</ToolTitle>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "websearch",
|
||||
container: "inline",
|
||||
render(props: ToolProps<any>) {
|
||||
const input = props.input as any
|
||||
const metadata = props.metadata as any
|
||||
return (
|
||||
<ToolTitle icon="◈" fallback="Searching web..." when={input.query}>
|
||||
Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
|
||||
</ToolTitle>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof EditTool>({
|
||||
name: "edit",
|
||||
container: "block",
|
||||
@@ -1452,7 +1471,12 @@ ToolRegistry.register<typeof EditTool>({
|
||||
const ctx = use()
|
||||
const { theme, syntax } = useTheme()
|
||||
|
||||
const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked"))
|
||||
const style = createMemo(() => {
|
||||
const diffStyle = ctx.sync.data.config.tui?.diff_style
|
||||
if (diffStyle === "stacked") return "stacked"
|
||||
// Default to "auto" behavior
|
||||
return ctx.width > 120 ? "split" : "stacked"
|
||||
})
|
||||
|
||||
const diff = createMemo(() => {
|
||||
const diff = props.metadata.diff ?? props.permission["diff"]
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, For, Show, Switch, Match, createSignal } from "solid-js"
|
||||
import { createMemo, For, Show, Switch, Match } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { Locale } from "@/util/locale"
|
||||
import path from "path"
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk"
|
||||
import { Global } from "@/global"
|
||||
import { Installation } from "@/installation"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
|
||||
export function Sidebar(props: { sessionID: string }) {
|
||||
const sync = useSync()
|
||||
@@ -13,10 +18,12 @@ export function Sidebar(props: { sessionID: string }) {
|
||||
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
|
||||
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
|
||||
|
||||
const [mcpExpanded, setMcpExpanded] = createSignal(true)
|
||||
const [diffExpanded, setDiffExpanded] = createSignal(true)
|
||||
const [todoExpanded, setTodoExpanded] = createSignal(true)
|
||||
const [lspExpanded, setLspExpanded] = createSignal(true)
|
||||
const [expanded, setExpanded] = createStore({
|
||||
mcp: true,
|
||||
diff: true,
|
||||
todo: true,
|
||||
lsp: true,
|
||||
})
|
||||
|
||||
// Sort MCP servers alphabetically for consistent display order
|
||||
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
|
||||
@@ -41,87 +48,104 @@ export function Sidebar(props: { sessionID: string }) {
|
||||
}
|
||||
})
|
||||
|
||||
const keybind = useKeybind()
|
||||
const directory = useDirectory()
|
||||
|
||||
const hasProviders = createMemo(() =>
|
||||
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={session()}>
|
||||
<scrollbox width={40}>
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<box>
|
||||
<text fg={theme.text}>
|
||||
<b>{session().title}</b>
|
||||
</text>
|
||||
<Show when={session().share?.url}>
|
||||
<text fg={theme.textMuted}>{session().share!.url}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.text}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
|
||||
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
|
||||
<text fg={theme.textMuted}>{cost()} spent</text>
|
||||
</box>
|
||||
<Show when={mcpEntries().length > 0}>
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
width={42}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
>
|
||||
<scrollbox flexGrow={1}>
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => mcpEntries().length > 2 && setMcpExpanded(!mcpExpanded())}
|
||||
>
|
||||
<Show when={mcpEntries().length > 2}>
|
||||
<text fg={theme.text}>{mcpExpanded() ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>MCP</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={mcpEntries().length <= 2 || mcpExpanded()}>
|
||||
<For each={mcpEntries()}>
|
||||
{([key, item]) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: theme.success,
|
||||
failed: theme.error,
|
||||
disabled: theme.textMuted,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{key}{" "}
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
<Switch>
|
||||
<Match when={item.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
|
||||
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<text fg={theme.text}>
|
||||
<b>{session().title}</b>
|
||||
</text>
|
||||
<Show when={session().share?.url}>
|
||||
<text fg={theme.textMuted}>{session().share!.url}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={sync.data.lsp.length > 0}>
|
||||
<box>
|
||||
<text fg={theme.text}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
|
||||
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
|
||||
<text fg={theme.textMuted}>{cost()} spent</text>
|
||||
</box>
|
||||
<Show when={mcpEntries().length > 0}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)}
|
||||
>
|
||||
<Show when={mcpEntries().length > 2}>
|
||||
<text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>MCP</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={mcpEntries().length <= 2 || expanded.mcp}>
|
||||
<For each={mcpEntries()}>
|
||||
{([key, item]) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: theme.success,
|
||||
failed: theme.error,
|
||||
disabled: theme.textMuted,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{key}{" "}
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
<Switch>
|
||||
<Match when={item.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
|
||||
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => sync.data.lsp.length > 2 && setLspExpanded(!lspExpanded())}
|
||||
onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)}
|
||||
>
|
||||
<Show when={sync.data.lsp.length > 2}>
|
||||
<text fg={theme.text}>{lspExpanded() ? "▼" : "▶"}</text>
|
||||
<text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>LSP</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={sync.data.lsp.length <= 2 || lspExpanded()}>
|
||||
<Show when={sync.data.lsp.length <= 2 || expanded.lsp}>
|
||||
<Show when={sync.data.lsp.length === 0}>
|
||||
<text fg={theme.textMuted}>LSPs will activate as files are read</text>
|
||||
</Show>
|
||||
<For each={sync.data.lsp}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
@@ -144,78 +168,115 @@ export function Sidebar(props: { sessionID: string }) {
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={todo().length > 0}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => todo().length > 2 && setTodoExpanded(!todoExpanded())}
|
||||
>
|
||||
<Show when={todo().length > 2}>
|
||||
<text fg={theme.text}>{todoExpanded() ? "▼" : "▶"}</text>
|
||||
<Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
|
||||
>
|
||||
<Show when={todo().length > 2}>
|
||||
<text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>Todo</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={todo().length <= 2 || expanded.todo}>
|
||||
<For each={todo()}>
|
||||
{(todo) => (
|
||||
<text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
|
||||
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>Todo</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={todo().length <= 2 || todoExpanded()}>
|
||||
<For each={todo()}>
|
||||
{(todo) => (
|
||||
<text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
|
||||
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={diff().length > 0}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => diff().length > 2 && setDiffExpanded(!diffExpanded())}
|
||||
>
|
||||
<Show when={diff().length > 2}>
|
||||
<text fg={theme.text}>{diffExpanded() ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>Modified Files</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={diff().length <= 2 || diffExpanded()}>
|
||||
<For each={diff() || []}>
|
||||
{(item) => {
|
||||
const file = createMemo(() => {
|
||||
const splits = item.file.split(path.sep).filter(Boolean)
|
||||
const last = splits.at(-1)!
|
||||
const rest = splits.slice(0, -1).join(path.sep)
|
||||
if (!rest) return last
|
||||
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
|
||||
})
|
||||
return (
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text fg={theme.textMuted} wrapMode="char">
|
||||
{file()}
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={item.additions}>
|
||||
<text fg={theme.diffAdded}>+{item.additions}</text>
|
||||
</Show>
|
||||
<Show when={item.deletions}>
|
||||
<text fg={theme.diffRemoved}>-{item.deletions}</text>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={diff().length > 0}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
|
||||
>
|
||||
<Show when={diff().length > 2}>
|
||||
<text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>Modified Files</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={diff().length <= 2 || expanded.diff}>
|
||||
<For each={diff() || []}>
|
||||
{(item) => {
|
||||
const file = createMemo(() => {
|
||||
const splits = item.file.split(path.sep).filter(Boolean)
|
||||
const last = splits.at(-1)!
|
||||
const rest = splits.slice(0, -1).join(path.sep)
|
||||
if (!rest) return last
|
||||
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
|
||||
})
|
||||
return (
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text fg={theme.textMuted} wrapMode="char">
|
||||
{file()}
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={item.additions}>
|
||||
<text fg={theme.diffAdded}>+{item.additions}</text>
|
||||
</Show>
|
||||
<Show when={item.deletions}>
|
||||
<text fg={theme.diffRemoved}>-{item.deletions}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1}>
|
||||
<Show when={!hasProviders()}>
|
||||
<box
|
||||
backgroundColor={theme.backgroundElement}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
>
|
||||
<text flexShrink={0}>⬖</text>
|
||||
<box flexGrow={1} gap={1}>
|
||||
<text>
|
||||
<b>Getting started</b>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
|
||||
<text fg={theme.textMuted}>
|
||||
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text>Connect provider</text>
|
||||
<text fg={theme.textMuted}>/connect</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.success }}>•</span> <b>Open</b>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>Code</b>
|
||||
</span>{" "}
|
||||
<span>{Installation.VERSION}</span>
|
||||
</text>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={1}>
|
||||
<box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={2}>
|
||||
<For each={props.keybind ?? []}>
|
||||
{(item) => (
|
||||
<text>
|
||||
|
||||
@@ -38,3 +38,18 @@ export function FormatError(input: unknown) {
|
||||
|
||||
if (UI.CancelledError.isInstance(input)) return ""
|
||||
}
|
||||
|
||||
export function FormatUnknownError(input: unknown): string {
|
||||
if (input instanceof Error) {
|
||||
return input.stack ?? `${input.name}: ${input.message}`
|
||||
}
|
||||
|
||||
if (typeof input === "object" && input !== null) {
|
||||
try {
|
||||
const json = JSON.stringify(input, null, 2)
|
||||
if (json && json !== "{}") return json
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return String(input)
|
||||
}
|
||||
|
||||
@@ -456,6 +456,10 @@ export namespace Config {
|
||||
})
|
||||
.optional()
|
||||
.describe("Scroll acceleration settings"),
|
||||
diff_style: z
|
||||
.enum(["auto", "stacked"])
|
||||
.optional()
|
||||
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
|
||||
})
|
||||
|
||||
export const Layout = z.enum(["auto", "stretch"]).meta({
|
||||
@@ -540,6 +544,10 @@ export namespace Config {
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
|
||||
setCacheKey: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Enable promptCacheKey for this provider (default false)"),
|
||||
timeout: z
|
||||
.union([
|
||||
z
|
||||
|
||||
@@ -6,6 +6,7 @@ export namespace FileIgnore {
|
||||
"bower_components",
|
||||
".pnpm-store",
|
||||
"vendor",
|
||||
".npm",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
@@ -22,12 +23,21 @@ export namespace FileIgnore {
|
||||
".output",
|
||||
"desktop",
|
||||
".sst",
|
||||
".cache",
|
||||
".webkit-cache",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
"mypy_cache",
|
||||
".history",
|
||||
".gradle",
|
||||
])
|
||||
|
||||
const FILES = [
|
||||
"**/*.swp",
|
||||
"**/*.swo",
|
||||
|
||||
"**/*.pyc",
|
||||
|
||||
// OS
|
||||
"**/.DS_Store",
|
||||
"**/Thumbs.db",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Log } from "../util/log"
|
||||
import { FileIgnore } from "./ignore"
|
||||
@@ -8,6 +7,7 @@ import { Config } from "../config/config"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
|
||||
export namespace FileWatcher {
|
||||
const log = Log.create({ service: "file.watcher" })
|
||||
@@ -44,32 +44,46 @@ export namespace FileWatcher {
|
||||
return {}
|
||||
}
|
||||
log.info("watcher backend", { platform: process.platform, backend })
|
||||
const sub = await watcher().subscribe(
|
||||
Instance.directory,
|
||||
(err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
log.info("event", evt)
|
||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||
}
|
||||
},
|
||||
{
|
||||
ignore: [...FileIgnore.PATTERNS, ...(cfg.watcher?.ignore ?? [])],
|
||||
const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
log.info("event", evt)
|
||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||
}
|
||||
}
|
||||
|
||||
const subs = []
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
subs.push(
|
||||
await watcher().subscribe(Instance.directory, subscribe, {
|
||||
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
|
||||
backend,
|
||||
},
|
||||
}),
|
||||
)
|
||||
return { sub }
|
||||
|
||||
const vcsDir = Instance.project.vcsDir
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
subs.push(
|
||||
await watcher().subscribe(vcsDir, subscribe, {
|
||||
ignore: ["hooks", "info", "logs", "objects", "refs", "worktrees", "modules", "lfs"],
|
||||
backend,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return { subs }
|
||||
},
|
||||
async (state) => {
|
||||
if (!state.sub) return
|
||||
await state.sub?.unsubscribe()
|
||||
if (!state.subs) return
|
||||
await Promise.all(state.subs.map((sub) => sub?.unsubscribe()))
|
||||
},
|
||||
)
|
||||
|
||||
export function init() {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_WATCHER) return
|
||||
// if (!Flag.OPENCODE_EXPERIMENTAL_WATCHER) return
|
||||
state()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ export namespace Flag {
|
||||
// Experimental
|
||||
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
export const OPENCODE_EXPERIMENTAL_WATCHER = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WATCHER")
|
||||
export const OPENCODE_EXPERIMENTAL_EXA = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
|
||||
@@ -88,9 +88,7 @@ export namespace LSPServer {
|
||||
),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
||||
async spawn(root) {
|
||||
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {})
|
||||
if (!tsserver) return
|
||||
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
|
||||
const proc = spawn(BunProc.which(), ["x", "@vtsls/language-server", "--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -99,11 +97,6 @@ export namespace LSPServer {
|
||||
})
|
||||
return {
|
||||
process: proc,
|
||||
initialization: {
|
||||
tsserver: {
|
||||
path: tsserver,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import { Format } from "../format"
|
||||
import { LSP } from "../lsp"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { File } from "../file"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Project } from "./project"
|
||||
import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { Instance } from "./instance"
|
||||
import { Vcs } from "./vcs"
|
||||
import { Log } from "@/util/log"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function InstanceBootstrap() {
|
||||
await LSP.init()
|
||||
FileWatcher.init()
|
||||
File.init()
|
||||
Vcs.init()
|
||||
|
||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
|
||||
@@ -12,6 +12,7 @@ export namespace Project {
|
||||
.object({
|
||||
id: z.string(),
|
||||
worktree: z.string(),
|
||||
vcsDir: z.string().optional(),
|
||||
vcs: z.literal("git").optional(),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
@@ -80,9 +81,16 @@ export namespace Project {
|
||||
.cwd(worktree)
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const vcsDir = await $`git rev-parse --path-format=absolute --git-dir`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(worktree)
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const project: Info = {
|
||||
id,
|
||||
worktree,
|
||||
vcsDir,
|
||||
vcs: "git",
|
||||
time: {
|
||||
created: Date.now(),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Log } from "@/util/log"
|
||||
import { Bus } from "@/bus"
|
||||
import { Instance } from "./instance"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
|
||||
export namespace Vcs {
|
||||
export const Event = {
|
||||
BranchUpdated: Bus.event(
|
||||
"vcs.branch.updated",
|
||||
z.object({
|
||||
branch: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
branch: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "VcsInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
async function currentBranch() {
|
||||
return $`git rev-parse --abbrev-ref HEAD`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(Instance.worktree)
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const state = Instance.state(
|
||||
async () => {
|
||||
const vcsDir = Instance.project.vcsDir
|
||||
if (Instance.project.vcs !== "git" || !vcsDir) {
|
||||
return { branch: async () => undefined, unsubscribe: undefined }
|
||||
}
|
||||
let current = await currentBranch()
|
||||
log.info("initialized", { branch: current })
|
||||
|
||||
const head = path.join(vcsDir, "HEAD")
|
||||
const unsubscribe = Bus.subscribe(FileWatcher.Event.Updated, async (evt) => {
|
||||
if (evt.properties.file !== head) return
|
||||
const next = await currentBranch()
|
||||
if (next !== current) {
|
||||
log.info("branch changed", { from: current, to: next })
|
||||
current = next
|
||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
branch: async () => current,
|
||||
unsubscribe,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
state.unsubscribe?.()
|
||||
},
|
||||
)
|
||||
|
||||
export async function init() {
|
||||
return state()
|
||||
}
|
||||
|
||||
export async function branch() {
|
||||
return await state().then((s) => s.branch())
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,11 @@ export namespace Provider {
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
},
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
// Skip region prefixing if model already has global prefix
|
||||
if (modelID.startsWith("global.")) {
|
||||
return sdk.languageModel(modelID)
|
||||
}
|
||||
|
||||
let regionPrefix = region.split("-")[0]
|
||||
|
||||
switch (regionPrefix) {
|
||||
|
||||
@@ -128,7 +128,13 @@ export namespace ProviderTransform {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function options(providerID: string, modelID: string, npm: string, sessionID: string): Record<string, any> {
|
||||
export function options(
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
npm: string,
|
||||
sessionID: string,
|
||||
providerOptions?: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
// switch to providerID later, for now use this
|
||||
@@ -138,7 +144,7 @@ export namespace ProviderTransform {
|
||||
}
|
||||
}
|
||||
|
||||
if (providerID === "openai") {
|
||||
if (providerID === "openai" || providerOptions?.setCacheKey) {
|
||||
result["promptCacheKey"] = sessionID
|
||||
}
|
||||
|
||||
@@ -248,7 +254,7 @@ export namespace ProviderTransform {
|
||||
return standardLimit
|
||||
}
|
||||
|
||||
export function schema(_providerID: string, _modelID: string, schema: JSONSchema.BaseSchema) {
|
||||
export function schema(providerID: string, modelID: string, schema: JSONSchema.BaseSchema) {
|
||||
/*
|
||||
if (["openai", "azure"].includes(providerID)) {
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
@@ -265,11 +271,40 @@ export namespace ProviderTransform {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (providerID === "google") {
|
||||
}
|
||||
*/
|
||||
|
||||
// Convert integer enums to string enums for Google/Gemini
|
||||
if (providerID === "google" || modelID.includes("gemini")) {
|
||||
const convertIntEnumsToStrings = (obj: any): any => {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(convertIntEnumsToStrings)
|
||||
}
|
||||
|
||||
const result: any = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (key === "enum" && Array.isArray(value)) {
|
||||
// Convert all enum values to strings
|
||||
result[key] = value.map((v) => String(v))
|
||||
// If we have integer type with enum, change type to string
|
||||
if (result.type === "integer" || result.type === "number") {
|
||||
result.type = "string"
|
||||
}
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
result[key] = convertIntEnumsToStrings(value)
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
schema = convertIntEnumsToStrings(schema)
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { MessageV2 } from "../session/message-v2"
|
||||
import { TuiRoute } from "./tui"
|
||||
import { Permission } from "../permission"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Vcs } from "../project/vcs"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Auth } from "../auth"
|
||||
import { Command } from "../command"
|
||||
@@ -365,6 +366,29 @@ export namespace Server {
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/vcs",
|
||||
describeRoute({
|
||||
description: "Get VCS info for the current instance",
|
||||
operationId: "vcs.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "VCS info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Vcs.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const branch = await Vcs.branch()
|
||||
return c.json({
|
||||
branch,
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/session",
|
||||
describeRoute({
|
||||
|
||||
@@ -339,7 +339,7 @@ export namespace SessionProcessor {
|
||||
SessionStatus.set(input.sessionID, {
|
||||
type: "retry",
|
||||
attempt,
|
||||
message: error.data.message,
|
||||
message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message,
|
||||
next: Date.now() + delay,
|
||||
})
|
||||
await SessionRetry.sleep(delay, input.abort).catch(() => {})
|
||||
|
||||
@@ -150,9 +150,12 @@ export namespace SessionPrompt {
|
||||
},
|
||||
]
|
||||
const files = ConfigMarkdown.files(template)
|
||||
const seen = new Set<string>()
|
||||
await Promise.all(
|
||||
files.map(async (match) => {
|
||||
const name = match[1]
|
||||
if (seen.has(name)) return
|
||||
seen.add(name)
|
||||
const filepath = name.startsWith("~/")
|
||||
? path.join(os.homedir(), name.slice(2))
|
||||
: path.resolve(Instance.worktree, name)
|
||||
@@ -477,13 +480,14 @@ export namespace SessionPrompt {
|
||||
tools: lastUser.tools,
|
||||
processor,
|
||||
})
|
||||
const provider = await Provider.getProvider(model.providerID)
|
||||
const params = await Plugin.trigger(
|
||||
"chat.params",
|
||||
{
|
||||
sessionID: sessionID,
|
||||
agent: lastUser.agent,
|
||||
model: model.info,
|
||||
provider: await Provider.getProvider(model.providerID),
|
||||
provider,
|
||||
message: lastUser,
|
||||
},
|
||||
{
|
||||
@@ -493,7 +497,9 @@ export namespace SessionPrompt {
|
||||
topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID),
|
||||
options: pipe(
|
||||
{},
|
||||
mergeDeep(ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", sessionID)),
|
||||
mergeDeep(
|
||||
ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", sessionID, provider?.options),
|
||||
),
|
||||
mergeDeep(model.info.options),
|
||||
mergeDeep(agent.options),
|
||||
),
|
||||
@@ -591,6 +597,21 @@ export namespace SessionPrompt {
|
||||
// @ts-expect-error
|
||||
args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID)
|
||||
}
|
||||
// Transform tool schemas for provider compatibility
|
||||
if (args.params.tools && Array.isArray(args.params.tools)) {
|
||||
args.params.tools = args.params.tools.map((tool: any) => {
|
||||
// Tools at middleware level have inputSchema, not parameters
|
||||
if (tool.inputSchema && typeof tool.inputSchema === "object") {
|
||||
// Transform the inputSchema for provider compatibility
|
||||
return {
|
||||
...tool,
|
||||
inputSchema: ProviderTransform.schema(model.providerID, model.modelID, tool.inputSchema),
|
||||
}
|
||||
}
|
||||
// If no inputSchema, return tool unchanged
|
||||
return tool
|
||||
})
|
||||
}
|
||||
return args.params
|
||||
},
|
||||
},
|
||||
@@ -730,6 +751,8 @@ export namespace SessionPrompt {
|
||||
if (Wildcard.all(key, enabledTools) === false) continue
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
|
||||
// Wrap execute to add plugin hooks and format output
|
||||
item.execute = async (args, opts) => {
|
||||
await Plugin.trigger(
|
||||
"tool.execute.before",
|
||||
@@ -757,17 +780,17 @@ export namespace SessionPrompt {
|
||||
const textParts: string[] = []
|
||||
const attachments: MessageV2.FilePart[] = []
|
||||
|
||||
for (const item of result.content) {
|
||||
if (item.type === "text") {
|
||||
textParts.push(item.text)
|
||||
} else if (item.type === "image") {
|
||||
for (const contentItem of result.content) {
|
||||
if (contentItem.type === "text") {
|
||||
textParts.push(contentItem.text)
|
||||
} else if (contentItem.type === "image") {
|
||||
attachments.push({
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
type: "file",
|
||||
mime: item.mimeType,
|
||||
url: `data:${item.mimeType};base64,${item.data}`,
|
||||
mime: contentItem.mimeType,
|
||||
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
|
||||
})
|
||||
}
|
||||
// Add support for other types if needed
|
||||
@@ -1412,9 +1435,18 @@ export namespace SessionPrompt {
|
||||
if (!isFirst) return
|
||||
const small =
|
||||
(await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
|
||||
const provider = await Provider.getProvider(small.providerID)
|
||||
const options = pipe(
|
||||
{},
|
||||
mergeDeep(ProviderTransform.options(small.providerID, small.modelID, small.npm ?? "", input.session.id)),
|
||||
mergeDeep(
|
||||
ProviderTransform.options(
|
||||
small.providerID,
|
||||
small.modelID,
|
||||
small.npm ?? "",
|
||||
input.session.id,
|
||||
provider?.options,
|
||||
),
|
||||
),
|
||||
mergeDeep(ProviderTransform.smallOptions({ providerID: small.providerID, modelID: small.modelID })),
|
||||
mergeDeep(small.info.options),
|
||||
)
|
||||
|
||||
@@ -97,8 +97,9 @@ export namespace ToolRegistry {
|
||||
WebFetchTool,
|
||||
TodoWriteTool,
|
||||
TodoReadTool,
|
||||
WebSearchTool,
|
||||
CodeSearchTool,
|
||||
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_EXA ? [WebSearchTool, CodeSearchTool] : []),
|
||||
...custom,
|
||||
]
|
||||
}
|
||||
@@ -107,13 +108,18 @@ export namespace ToolRegistry {
|
||||
return all().then((x) => x.map((t) => t.id))
|
||||
}
|
||||
|
||||
export async function tools(_providerID: string, _modelID: string) {
|
||||
export async function tools(providerID: string, _modelID: string) {
|
||||
const tools = await all()
|
||||
const result = await Promise.all(
|
||||
tools.map(async (t) => ({
|
||||
id: t.id,
|
||||
...(await t.init()),
|
||||
})),
|
||||
tools
|
||||
.filter((t) => {
|
||||
if (t.id === "codesearch" || t.id === "websearch") return providerID === "opencode"
|
||||
return true
|
||||
})
|
||||
.map(async (t) => ({
|
||||
id: t.id,
|
||||
...(await t.init()),
|
||||
})),
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -23,6 +23,8 @@ import type {
|
||||
InstanceDisposeResponses,
|
||||
PathGetData,
|
||||
PathGetResponses,
|
||||
VcsGetData,
|
||||
VcsGetResponses,
|
||||
SessionListData,
|
||||
SessionListResponses,
|
||||
SessionCreateData,
|
||||
@@ -311,6 +313,18 @@ class Path extends _HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
class Vcs extends _HeyApiClient {
|
||||
/**
|
||||
* Get VCS info for the current instance
|
||||
*/
|
||||
public get<ThrowOnError extends boolean = false>(options?: Options<VcsGetData, ThrowOnError>) {
|
||||
return (options?.client ?? this._client).get<VcsGetResponses, unknown, ThrowOnError>({
|
||||
url: "/vcs",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class Session extends _HeyApiClient {
|
||||
/**
|
||||
* List all sessions
|
||||
@@ -995,6 +1009,7 @@ export class OpencodeClient extends _HeyApiClient {
|
||||
tool = new Tool({ client: this._client })
|
||||
instance = new Instance({ client: this._client })
|
||||
path = new Path({ client: this._client })
|
||||
vcs = new Vcs({ client: this._client })
|
||||
session = new Session({ client: this._client })
|
||||
command = new Command({ client: this._client })
|
||||
provider = new Provider({ client: this._client })
|
||||
|
||||
@@ -589,6 +589,21 @@ export type EventSessionError = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileWatcherUpdated = {
|
||||
type: "file.watcher.updated"
|
||||
properties: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventVcsBranchUpdated = {
|
||||
type: "vcs.branch.updated"
|
||||
properties: {
|
||||
branch?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventTuiPromptAppend = {
|
||||
type: "tui.prompt.append"
|
||||
properties: {
|
||||
@@ -640,14 +655,6 @@ export type EventServerConnected = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileWatcherUpdated = {
|
||||
type: "file.watcher.updated"
|
||||
properties: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
}
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| EventInstallationUpdated
|
||||
| EventInstallationUpdateAvailable
|
||||
@@ -670,11 +677,12 @@ export type Event =
|
||||
| EventSessionDeleted
|
||||
| EventSessionDiff
|
||||
| EventSessionError
|
||||
| EventFileWatcherUpdated
|
||||
| EventVcsBranchUpdated
|
||||
| EventTuiPromptAppend
|
||||
| EventTuiCommandExecute
|
||||
| EventTuiToastShow
|
||||
| EventServerConnected
|
||||
| EventFileWatcherUpdated
|
||||
|
||||
export type GlobalEvent = {
|
||||
directory: string
|
||||
@@ -684,6 +692,7 @@ export type GlobalEvent = {
|
||||
export type Project = {
|
||||
id: string
|
||||
worktree: string
|
||||
vcsDir?: string
|
||||
vcs?: "git"
|
||||
time: {
|
||||
created: number
|
||||
@@ -995,6 +1004,10 @@ export type Config = {
|
||||
*/
|
||||
enabled: boolean
|
||||
}
|
||||
/**
|
||||
* Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column
|
||||
*/
|
||||
diff_style?: "auto" | "stacked"
|
||||
}
|
||||
/**
|
||||
* Command configuration, see https://opencode.ai/docs/commands
|
||||
@@ -1123,11 +1136,15 @@ export type Config = {
|
||||
* GitHub Enterprise URL for copilot authentication
|
||||
*/
|
||||
enterpriseUrl?: string
|
||||
/**
|
||||
* Enable promptCacheKey for this provider (default false)
|
||||
*/
|
||||
setCacheKey?: boolean
|
||||
/**
|
||||
* Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.
|
||||
*/
|
||||
timeout?: number | false
|
||||
[key: string]: unknown | string | (number | false) | undefined
|
||||
[key: string]: unknown | string | boolean | (number | false) | undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1247,6 +1264,10 @@ export type Path = {
|
||||
directory: string
|
||||
}
|
||||
|
||||
export type VcsInfo = {
|
||||
branch: string
|
||||
}
|
||||
|
||||
export type NotFoundError = {
|
||||
name: "NotFoundError"
|
||||
data: {
|
||||
@@ -1683,6 +1704,24 @@ export type PathGetResponses = {
|
||||
|
||||
export type PathGetResponse = PathGetResponses[keyof PathGetResponses]
|
||||
|
||||
export type VcsGetData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/vcs"
|
||||
}
|
||||
|
||||
export type VcsGetResponses = {
|
||||
/**
|
||||
* VCS info
|
||||
*/
|
||||
200: VcsInfo
|
||||
}
|
||||
|
||||
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
|
||||
|
||||
export type SessionListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -55,6 +55,9 @@ class Resource:
|
||||
class EMAILOCTOPUS_API_KEY:
|
||||
type: str
|
||||
value: str
|
||||
class Enterprise:
|
||||
type: str
|
||||
url: str
|
||||
class EnterpriseStorage:
|
||||
name: str
|
||||
type: str
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/tauri",
|
||||
"private": true,
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./src/components/*.tsx",
|
||||
"./pierre": "./src/components/pierre.ts",
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./context": "./src/context/index.ts",
|
||||
"./context/*": "./src/context/*.tsx",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/precision-diffs"
|
||||
import { ComponentProps, createEffect, splitProps } from "solid-js"
|
||||
import { createDefaultOptions, styleVariables } from "./pierre"
|
||||
|
||||
export type CodeProps<T = {}> = FileOptions<T> & {
|
||||
file: FileContents
|
||||
@@ -14,12 +15,7 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
|
||||
createEffect(() => {
|
||||
const instance = new File<T>({
|
||||
theme: "OpenCode",
|
||||
overflow: "wrap", // or 'scroll'
|
||||
themeType: "system", // 'system', 'light', or 'dark'
|
||||
disableFileHeader: true,
|
||||
disableLineNumbers: false, // optional
|
||||
// lang: 'typescript', // optional - auto-detected from filename if not provided
|
||||
...createDefaultOptions<T>("unified"),
|
||||
...others,
|
||||
})
|
||||
|
||||
@@ -34,16 +30,7 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
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,
|
||||
"--pjs-min-number-column-width": "4ch",
|
||||
}}
|
||||
style={styleVariables}
|
||||
classList={{
|
||||
...(local.classList || {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type FileContents, FileDiff, type DiffLineAnnotation, FileDiffOptions } from "@pierre/precision-diffs"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
|
||||
import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { ComponentProps, createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { isServer } from "solid-js/web"
|
||||
import { createDefaultOptions, styleVariables } from "./pierre"
|
||||
|
||||
export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
preloadedDiff?: PreloadMultiFileDiffResult<T>
|
||||
@@ -15,6 +16,8 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
// interface ThreadMetadata {
|
||||
// threadId: string
|
||||
// }
|
||||
//
|
||||
//
|
||||
|
||||
export function Diff<T>(props: DiffProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
@@ -24,27 +27,12 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
let fileDiffInstance: FileDiff<T> | undefined
|
||||
const cleanupFunctions: Array<() => void> = []
|
||||
|
||||
const defaultOptions: FileDiffOptions<T> = {
|
||||
theme: "OpenCode",
|
||||
themeType: "system",
|
||||
disableLineNumbers: false,
|
||||
overflow: "wrap",
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableBackground: false,
|
||||
expansionLineCount: 20,
|
||||
lineDiffType: props.diffStyle === "split" ? "word-alt" : "none",
|
||||
maxLineDiffLength: 1000,
|
||||
maxLineLengthForHighlighting: 1000,
|
||||
disableFileHeader: true,
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.preloadedDiff) return
|
||||
container.innerHTML = ""
|
||||
if (!fileDiffInstance) {
|
||||
fileDiffInstance = new FileDiff<T>({
|
||||
...defaultOptions,
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...(props.preloadedDiff ?? {}),
|
||||
})
|
||||
@@ -60,22 +48,19 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
onMount(() => {
|
||||
if (isServer) return
|
||||
fileDiffInstance = new FileDiff<T>({
|
||||
...defaultOptions,
|
||||
// You can optionally pass a render function for rendering out line
|
||||
// annotations. Just return the dom node to render
|
||||
// renderAnnotation(annotation: DiffLineAnnotation<T>): HTMLElement {
|
||||
// // Despite the diff itself being rendered in the shadow dom,
|
||||
// // annotations are inserted via the web components 'slots' api and you
|
||||
// // can use all your normal normal css and styling for them
|
||||
// const element = document.createElement("div")
|
||||
// element.innerText = annotation.metadata.threadId
|
||||
// return element
|
||||
// },
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...(props.preloadedDiff ?? {}),
|
||||
})
|
||||
// @ts-expect-error - fileContainer is private but needed for SSR hydration
|
||||
fileDiffInstance.fileContainer = fileDiffRef
|
||||
fileDiffInstance.hydrate({
|
||||
oldFile: local.before,
|
||||
newFile: local.after,
|
||||
lineAnnotations: local.annotations,
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
})
|
||||
|
||||
// Hydrate annotation slots with interactive SolidJS components
|
||||
// if (props.annotations.length > 0 && props.renderAnnotation != null) {
|
||||
@@ -108,38 +93,11 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="diff"
|
||||
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,
|
||||
"--pjs-min-number-column-width": "4ch",
|
||||
}}
|
||||
ref={container}
|
||||
>
|
||||
<div data-component="diff" style={styleVariables} ref={container}>
|
||||
<file-diff ref={fileDiffRef} id="ssr-diff">
|
||||
{/* Only render on server - client hydrates the existing content */}
|
||||
{isServer && props.preloadedDiff && (
|
||||
<>
|
||||
{/* Declarative Shadow DOM - browsers parse this and create a shadow root */}
|
||||
<template shadowrootmode="open">
|
||||
<div innerHTML={props.preloadedDiff!.prerenderedHTML} />
|
||||
</template>
|
||||
{/* Render static annotation slots on server.
|
||||
Client will clear these and mount interactive components. */}
|
||||
{/* <For each={props.annotations}> */}
|
||||
{/* {(annotation) => { */}
|
||||
{/* const slotName = `annotation-${annotation.side}-${annotation.lineNumber}` */}
|
||||
{/* return <div slot={slotName}>{props.renderAnnotation?.(annotation)}</div> */}
|
||||
{/* }} */}
|
||||
{/* </For> */}
|
||||
</>
|
||||
)}
|
||||
<Show when={isServer && props.preloadedDiff}>
|
||||
{(preloadedDiff) => <template shadowrootmode="open" innerHTML={preloadedDiff().prerenderedHTML} />}
|
||||
</Show>
|
||||
</file-diff>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -63,6 +63,17 @@
|
||||
|
||||
[data-component="tool-output"] {
|
||||
white-space: pre;
|
||||
padding: 8px 12px;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="edit-trigger"],
|
||||
|
||||
@@ -16,35 +16,26 @@ import { Checkbox } from "./checkbox"
|
||||
import { Diff } from "./diff"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Markdown } from "./markdown"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { sanitize, sanitizePart } from "@opencode-ai/util/sanitize"
|
||||
|
||||
export interface MessageProps {
|
||||
message: MessageType
|
||||
parts: PartType[]
|
||||
sanitize?: RegExp
|
||||
}
|
||||
|
||||
export interface MessagePartProps {
|
||||
part: PartType
|
||||
message: MessageType
|
||||
hideDetails?: boolean
|
||||
sanitize?: RegExp
|
||||
}
|
||||
|
||||
export type PartComponent = Component<MessagePartProps>
|
||||
|
||||
export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
|
||||
|
||||
function getFilename(path: string) {
|
||||
if (!path) return ""
|
||||
const trimmed = path.replace(/[\/]+$/, "")
|
||||
const parts = trimmed.split("/")
|
||||
return parts[parts.length - 1] ?? ""
|
||||
}
|
||||
|
||||
function getDirectory(path: string) {
|
||||
const parts = path.split("/")
|
||||
const dir = parts.slice(0, parts.length - 1).join("/")
|
||||
return dir ? dir + "/" : ""
|
||||
}
|
||||
|
||||
export function registerPartComponent(type: string, component: PartComponent) {
|
||||
PART_MAPPING[type] = component
|
||||
}
|
||||
@@ -57,21 +48,27 @@ export function Message(props: MessageProps) {
|
||||
</Match>
|
||||
<Match when={props.message.role === "assistant" && props.message}>
|
||||
{(assistantMessage) => (
|
||||
<AssistantMessageDisplay message={assistantMessage() as AssistantMessage} parts={props.parts} />
|
||||
<AssistantMessageDisplay
|
||||
message={assistantMessage() as AssistantMessage}
|
||||
parts={props.parts}
|
||||
sanitize={props.sanitize}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
|
||||
export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[]; sanitize?: RegExp }) {
|
||||
const filteredParts = createMemo(() => {
|
||||
return props.parts?.filter((x) => {
|
||||
if (x.type === "reasoning") return false
|
||||
return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
|
||||
})
|
||||
})
|
||||
return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
|
||||
return (
|
||||
<For each={filteredParts()}>{(part) => <Part part={part} message={props.message} sanitize={props.sanitize} />}</For>
|
||||
)
|
||||
}
|
||||
|
||||
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
|
||||
@@ -88,7 +85,13 @@ export function Part(props: MessagePartProps) {
|
||||
const component = createMemo(() => PART_MAPPING[props.part.type])
|
||||
return (
|
||||
<Show when={component()}>
|
||||
<Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} />
|
||||
<Dynamic
|
||||
component={component()}
|
||||
part={props.part}
|
||||
message={props.message}
|
||||
hideDetails={props.hideDetails}
|
||||
sanitize={props.sanitize}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -99,6 +102,7 @@ export interface ToolProps {
|
||||
tool: string
|
||||
output?: string
|
||||
hideDetails?: boolean
|
||||
sanitize?: RegExp
|
||||
}
|
||||
|
||||
export type ToolComponent = Component<ToolProps>
|
||||
@@ -166,6 +170,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
metadata={metadata}
|
||||
output={part.state.status === "completed" ? part.state.output : undefined}
|
||||
hideDetails={props.hideDetails}
|
||||
sanitize={props.sanitize}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -177,10 +182,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
|
||||
PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
const part = props.part as TextPart
|
||||
const sanitized = createMemo(() => (props.sanitize ? (sanitizePart(part, props.sanitize) as TextPart) : part))
|
||||
return (
|
||||
<Show when={part.text.trim()}>
|
||||
<div data-component="text-part">
|
||||
<Markdown text={part.text.trim()} />
|
||||
<Markdown text={sanitized().text.trim()} />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
@@ -205,7 +211,7 @@ ToolRegistry.register({
|
||||
icon="glasses"
|
||||
trigger={{
|
||||
title: "Read",
|
||||
subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
|
||||
subtitle: props.input.filePath ? getFilename(sanitize(props.input.filePath, props.sanitize)) : "",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -216,9 +222,12 @@ ToolRegistry.register({
|
||||
name: "list",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}>
|
||||
<BasicTool
|
||||
icon="bullet-list"
|
||||
trigger={{ title: "List", subtitle: getDirectory(sanitize(props.input.path, props.sanitize) || "/") }}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div data-component="tool-output">{props.output}</div>
|
||||
<div data-component="tool-output">{sanitize(props.output, props.sanitize)}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
@@ -321,12 +330,14 @@ ToolRegistry.register({
|
||||
icon="console"
|
||||
trigger={{
|
||||
title: "Shell",
|
||||
subtitle: "Ran " + props.input.command,
|
||||
subtitle: props.input.description,
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div data-component="tool-output">{props.output}</div>
|
||||
</Show>
|
||||
<div data-component="tool-output">
|
||||
<Markdown
|
||||
text={`\`\`\`command\n$ ${sanitize(props.input.command, props.sanitize)}${props.output ? "\n\n" + props.output : ""}\n\`\`\``}
|
||||
/>
|
||||
</div>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
@@ -344,9 +355,13 @@ ToolRegistry.register({
|
||||
<div data-slot="message-part-title">Edit</div>
|
||||
<div data-slot="message-part-path">
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
|
||||
<span data-slot="message-part-directory">
|
||||
{getDirectory(sanitize(props.input.filePath!, props.sanitize))}
|
||||
</span>
|
||||
</Show>
|
||||
<span data-slot="message-part-filename">{getFilename(props.input.filePath ?? "")}</span>
|
||||
<span data-slot="message-part-filename">
|
||||
{getFilename(sanitize(props.input.filePath ?? "", props.sanitize))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="message-part-actions">
|
||||
@@ -361,11 +376,11 @@ ToolRegistry.register({
|
||||
<div data-component="edit-content">
|
||||
<Diff
|
||||
before={{
|
||||
name: getFilename(props.metadata.filediff.path),
|
||||
name: getFilename(sanitize(props.metadata.filediff.path, props.sanitize)),
|
||||
contents: props.metadata.filediff.before,
|
||||
}}
|
||||
after={{
|
||||
name: getFilename(props.metadata.filediff.path),
|
||||
name: getFilename(sanitize(props.metadata.filediff.path, props.sanitize)),
|
||||
contents: props.metadata.filediff.after,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { AssistantMessage as AssistantMessageType, ToolPart } from "@openco
|
||||
|
||||
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
|
||||
const data = useData()
|
||||
const parts = createMemo(() => props.assistantMessages().flatMap((m) => data.part[m.id]))
|
||||
const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
|
||||
const parts = createMemo(() => props.assistantMessages().flatMap((m) => data.store.part[m.id]))
|
||||
const done = createMemo(() => props.done ?? false)
|
||||
const currentTask = createMemo(
|
||||
() =>
|
||||
@@ -26,8 +27,10 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
|
||||
let resolved = parts()
|
||||
const task = currentTask()
|
||||
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
|
||||
const messages = data.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant")
|
||||
resolved = messages?.flatMap((m) => data.part[m.id]) ?? parts()
|
||||
const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
|
||||
(m) => m.role === "assistant",
|
||||
)
|
||||
resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? parts()
|
||||
}
|
||||
return resolved
|
||||
})
|
||||
@@ -148,11 +151,11 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
|
||||
{(p) => {
|
||||
const part = p() as ToolPart
|
||||
const message = createMemo(() =>
|
||||
data.message[part.sessionID].find((m) => m.id === part.messageID),
|
||||
data.store.message[part.sessionID].find((m) => m.id === part.messageID),
|
||||
)
|
||||
return (
|
||||
<div data-slot="message-progress-item">
|
||||
<Part message={message()!} part={part} />
|
||||
<Part message={message()!} part={part} sanitize={sanitizer()} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { FileDiffOptions } from "@pierre/precision-diffs"
|
||||
|
||||
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
|
||||
return {
|
||||
theme: "OpenCode",
|
||||
themeType: "system",
|
||||
disableLineNumbers: false,
|
||||
overflow: "wrap",
|
||||
diffStyle: style,
|
||||
diffIndicators: "bars",
|
||||
disableBackground: false,
|
||||
expansionLineCount: 20,
|
||||
lineDiffType: style === "split" ? "word-alt" : "none",
|
||||
maxLineDiffLength: 1000,
|
||||
maxLineLengthForHighlighting: 1000,
|
||||
disableFileHeader: true,
|
||||
unsafeCSS: `
|
||||
[data-pjs-header],
|
||||
[data-pjs] {
|
||||
[data-separator-wrapper] {
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
[data-expand-button] {
|
||||
width: 6.5ch !important;
|
||||
height: 24px !important;
|
||||
justify-content: end !important;
|
||||
padding-left: 3ch !important;
|
||||
padding-inline: 1ch !important;
|
||||
}
|
||||
[data-separator-multi-button] {
|
||||
grid-template-rows: 10px 10px !important;
|
||||
[data-expand-button] {
|
||||
height: 12px !important;
|
||||
}
|
||||
}
|
||||
[data-separator-content] {
|
||||
height: 24px !important;
|
||||
}
|
||||
}`,
|
||||
// hunkSeparators(hunkData: HunkData) {
|
||||
// const fragment = document.createDocumentFragment()
|
||||
// const numCol = document.createElement("div")
|
||||
// numCol.innerHTML = `<svg data-slot="diff-hunk-separator-line-number-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.97978 14.0204L8.62623 13.6668L9.33334 12.9597L9.68689 13.3133L9.33333 13.6668L8.97978 14.0204ZM12 16.3335L12.3535 16.6871L12 17.0406L11.6464 16.687L12 16.3335ZM14.3131 13.3133L14.6667 12.9597L15.3738 13.6668L15.0202 14.0204L14.6667 13.6668L14.3131 13.3133ZM12.5 16.0002V16.5002H11.5V16.0002H12H12.5ZM9.33333 13.6668L9.68689 13.3133L12.3535 15.9799L12 16.3335L11.6464 16.687L8.97978 14.0204L9.33333 13.6668ZM12 16.3335L11.6464 15.9799L14.3131 13.3133L14.6667 13.6668L15.0202 14.0204L12.3535 16.6871L12 16.3335ZM6.5 8.00016V7.50016H8.5V8.00016V8.50016H6.5V8.00016ZM9.5 8.00016V7.50016H11.5V8.00016V8.50016H9.5V8.00016ZM12.5 8.00016V7.50016H14.5V8.00016V8.50016H12.5V8.00016ZM15.5 8.00016V7.50016H17.5V8.00016V8.50016H15.5V8.00016ZM12 10.5002H12.5V16.0002H12H11.5V10.5002H12Z" fill="currentColor"/></svg> `
|
||||
// numCol.dataset["slot"] = "diff-hunk-separator-line-number"
|
||||
// fragment.appendChild(numCol)
|
||||
// const contentCol = document.createElement("div")
|
||||
// contentCol.dataset["slot"] = "diff-hunk-separator-content"
|
||||
// const span = document.createElement("span")
|
||||
// span.dataset["slot"] = "diff-hunk-separator-content-span"
|
||||
// span.textContent = `${hunkData.lines} unmodified lines`
|
||||
// contentCol.appendChild(span)
|
||||
// fragment.appendChild(contentCol)
|
||||
// return fragment
|
||||
// },
|
||||
} as const
|
||||
}
|
||||
|
||||
export const styleVariables = {
|
||||
"--pjs-font-family": "var(--font-family-mono)",
|
||||
"--pjs-font-size": "var(--font-size-small)",
|
||||
"--pjs-line-height": "24px",
|
||||
"--pjs-tab-size": 2,
|
||||
"--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
|
||||
"--pjs-header-font-family": "var(--font-family-sans)",
|
||||
"--pjs-gap-block": 0,
|
||||
"--pjs-min-number-column-width": "4ch",
|
||||
}
|
||||
@@ -14,6 +14,7 @@ interface SelectDialogProps<T>
|
||||
emptyMessage?: string
|
||||
children: (item: T) => JSX.Element
|
||||
onSelect?: (value: T | undefined) => void
|
||||
onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
|
||||
}
|
||||
|
||||
export function SelectDialog<T>(props: SelectDialogProps<T>) {
|
||||
@@ -65,9 +66,12 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
|
||||
setStore("mouseActive", false)
|
||||
if (e.key === "Escape") return
|
||||
|
||||
const all = flat()
|
||||
const selected = all.find((x) => others.key(x) === active())
|
||||
props.onKeyEvent?.(e, selected)
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const selected = flat().find((x) => others.key(x) === active())
|
||||
if (selected) handleSelect(selected)
|
||||
} else {
|
||||
onKeyDown(e)
|
||||
|
||||
@@ -28,10 +28,11 @@ export function SessionTurn(
|
||||
}>,
|
||||
) {
|
||||
const data = useData()
|
||||
const match = Binary.search(data.session, props.sessionID, (s) => s.id)
|
||||
const match = Binary.search(data.store.session, props.sessionID, (s) => s.id)
|
||||
if (!match.found) throw new Error(`Session ${props.sessionID} not found`)
|
||||
|
||||
const messages = createMemo(() => (props.sessionID ? (data.message[props.sessionID] ?? []) : []))
|
||||
const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
|
||||
const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : []))
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
@@ -44,7 +45,7 @@ export function SessionTurn(
|
||||
|
||||
const status = createMemo(
|
||||
() =>
|
||||
data.session_status[props.sessionID] ?? {
|
||||
data.store.session_status[props.sessionID] ?? {
|
||||
type: "idle",
|
||||
},
|
||||
)
|
||||
@@ -64,9 +65,9 @@ export function SessionTurn(
|
||||
const assistantMessages = createMemo(() => {
|
||||
return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[]
|
||||
})
|
||||
const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.part[m.id]))
|
||||
const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
|
||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||
const parts = createMemo(() => data.part[msg().id])
|
||||
const parts = createMemo(() => data.store.part[msg().id])
|
||||
const lastTextPart = createMemo(() =>
|
||||
assistantMessageParts()
|
||||
.filter((p) => p?.type === "text")
|
||||
@@ -116,7 +117,7 @@ export function SessionTurn(
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-turn-message-content">
|
||||
<Message message={msg()} parts={parts()} />
|
||||
<Message message={msg()} parts={parts()} sanitize={sanitizer()} />
|
||||
</div>
|
||||
{/* Summary */}
|
||||
<Show when={completed()}>
|
||||
@@ -211,7 +212,7 @@ export function SessionTurn(
|
||||
<div data-slot="session-turn-collapsible-content-inner">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(() => data.part[assistantMessage.id])
|
||||
const parts = createMemo(() => data.store.part[assistantMessage.id])
|
||||
const last = createMemo(() =>
|
||||
parts()
|
||||
.filter((p) => p?.type === "text")
|
||||
@@ -222,10 +223,11 @@ export function SessionTurn(
|
||||
<Message
|
||||
message={assistantMessage}
|
||||
parts={parts().filter((p) => p?.id !== last()?.id)}
|
||||
sanitize={sanitizer()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <Message message={assistantMessage} parts={parts()} />
|
||||
return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} />
|
||||
}}
|
||||
</For>
|
||||
<Show when={error()}>
|
||||
|
||||
@@ -23,7 +23,7 @@ type Data = {
|
||||
|
||||
export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
name: "Data",
|
||||
init: (props: { data: Data }) => {
|
||||
return props.data
|
||||
init: (props: { data: Data; directory: string }) => {
|
||||
return { store: props.data, directory: props.directory }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Part } from "@opencode-ai/sdk/client"
|
||||
|
||||
export const sanitize = (text: string | undefined, remove?: RegExp) => (remove ? text?.replace(remove, "") : text) ?? ""
|
||||
|
||||
export const sanitizePart = (part: Part, remove: RegExp) => {
|
||||
if (part.type === "text") {
|
||||
part.text = sanitize(part.text, remove)
|
||||
} else if (part.type === "reasoning") {
|
||||
part.text = sanitize(part.text, remove)
|
||||
} else if (part.type === "tool") {
|
||||
if (part.state.status === "completed" || part.state.status === "error") {
|
||||
for (const key in part.state.metadata) {
|
||||
if (typeof part.state.metadata[key] === "string") {
|
||||
part.state.metadata[key] = sanitize(part.state.metadata[key] as string, remove)
|
||||
}
|
||||
}
|
||||
for (const key in part.state.input) {
|
||||
if (typeof part.state.input[key] === "string") {
|
||||
part.state.input[key] = sanitize(part.state.input[key] as string, remove)
|
||||
}
|
||||
}
|
||||
if ("error" in part.state) {
|
||||
part.state.error = sanitize(part.state.error as string, remove)
|
||||
}
|
||||
}
|
||||
}
|
||||
return part
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -45,6 +45,8 @@ Or you can set it up manually.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
opencode:
|
||||
@@ -129,3 +131,20 @@ Here are some examples of how you can use opencode in GitHub.
|
||||
```
|
||||
|
||||
opencode will implement the requested change and commit it to the same PR.
|
||||
|
||||
- **Review specific code lines**
|
||||
|
||||
Leave a comment directly on code lines in the PR's "Files" tab. opencode automatically detects the file, line numbers, and diff context to provide precise responses.
|
||||
|
||||
```
|
||||
[Comment on specific lines in Files tab]
|
||||
/oc add error handling here
|
||||
```
|
||||
|
||||
When commenting on specific lines, opencode receives:
|
||||
- The exact file being reviewed
|
||||
- The specific lines of code
|
||||
- The surrounding diff context
|
||||
- Line number information
|
||||
|
||||
This allows for more targeted requests without needing to specify file paths or line numbers manually.
|
||||
|
||||
@@ -652,6 +652,82 @@ The `global` region improves availability and reduces errors at no extra cost. U
|
||||
|
||||
---
|
||||
|
||||
### llama.cpp
|
||||
|
||||
You can configure opencode to use local models through [llama.cpp's](https://github.com/ggml-org/llama.cpp) llama-server utility
|
||||
|
||||
```json title="opencode.json" "llama.cpp" {5, 6, 8, 10-14}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"llama.cpp": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "llama-server (local)",
|
||||
"options": {
|
||||
"baseURL": "http://127.0.0.1:8080/v1"
|
||||
},
|
||||
"models": {
|
||||
"qwen3-coder:a3b": {
|
||||
"name": "Qwen3-Coder: a3b-30b (local)"
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"context": 128000,
|
||||
"output": 65536
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
- `llama.cpp` is the custom provider ID. This can be any string you want.
|
||||
- `npm` specifies the package to use for this provider. Here, `@ai-sdk/openai-compatible` is used for any OpenAI-compatible API.
|
||||
- `name` is the display name for the provider in the UI.
|
||||
- `options.baseURL` is the endpoint for the local server.
|
||||
- `models` is a map of model IDs to their configurations. The model name will be displayed in the model selection list.
|
||||
|
||||
---
|
||||
|
||||
### IO.NET
|
||||
|
||||
IO.NET offers 17 models optimized for various use cases:
|
||||
|
||||
1. Head over to the [IO.NET console](https://ai.io.net/), create an account, and generate an API key.
|
||||
|
||||
2. Run `opencode auth login` and select **IO.NET**.
|
||||
|
||||
```bash
|
||||
$ opencode auth login
|
||||
|
||||
┌ Add credential
|
||||
│
|
||||
◆ Select provider
|
||||
│ ● IO.NET
|
||||
│ ...
|
||||
└
|
||||
```
|
||||
|
||||
3. Enter your IO.NET API key.
|
||||
|
||||
```bash
|
||||
$ opencode auth login
|
||||
|
||||
┌ Add credential
|
||||
│
|
||||
◇ Select provider
|
||||
│ IO.NET
|
||||
│
|
||||
◇ Enter your API key
|
||||
│ _
|
||||
└
|
||||
```
|
||||
|
||||
4. Run the `/models` command to select a model.
|
||||
|
||||
---
|
||||
|
||||
### LM Studio
|
||||
|
||||
You can configure opencode to use local models through LM Studio.
|
||||
@@ -1293,3 +1369,39 @@ If you are having trouble with configuring a provider, check the following:
|
||||
- Make sure the provider ID used in `opencode auth login` matches the ID in your opencode config.
|
||||
- The right npm package is used for the provider. For example, use `@ai-sdk/cerebras` for Cerebras. And for all other OpenAI-compatible providers, use `@ai-sdk/openai-compatible`.
|
||||
- Check correct API endpoint is used in the `options.baseURL` field.
|
||||
|
||||
---
|
||||
|
||||
### Venice AI
|
||||
|
||||
1. Head over to the [Venice AI console](https://venice.ai), create an account, and generate an API key.
|
||||
|
||||
2. Run `opencode auth login` and select **Venice AI**.
|
||||
|
||||
```bash
|
||||
$ opencode auth login
|
||||
|
||||
┌ Add credential
|
||||
│
|
||||
◆ Select provider
|
||||
│ ● Venice AI
|
||||
│ ...
|
||||
└
|
||||
```
|
||||
|
||||
3. Enter your Venice AI API key.
|
||||
|
||||
```bash
|
||||
$ opencode auth login
|
||||
|
||||
┌ Add credential
|
||||
│
|
||||
◇ Select provider
|
||||
│ Venice AI
|
||||
│
|
||||
◇ Enter your API key
|
||||
│ _
|
||||
└
|
||||
```
|
||||
|
||||
4. Run the `/models` command to select a model like _Llama 3.3 70B_.
|
||||
|
||||
@@ -138,10 +138,6 @@ The free models:
|
||||
- Grok Code Fast 1 is currently free on OpenCode for a limited time. The xAI team is using this time to collect feedback and improve Grok Code.
|
||||
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
|
||||
:::tip
|
||||
Subscription plans and a free tier are coming soon.
|
||||
:::
|
||||
|
||||
<a href={email}>Contact us</a> if you have any questions.
|
||||
|
||||
---
|
||||
|
||||
@@ -215,6 +215,11 @@ site-search > button span {
|
||||
.starlight-aside__content {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
site-search > button > kbd {
|
||||
|
||||
+2
-1
@@ -35,7 +35,7 @@ if (!Script.preview) {
|
||||
body: {
|
||||
model: {
|
||||
providerID: "opencode",
|
||||
modelID: "kimi-k2",
|
||||
modelID: "claude-haiku-4-5",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
@@ -50,6 +50,7 @@ if (!Script.preview) {
|
||||
- Do NOT include any information about code changes if they do not affect the user facing changes.
|
||||
- For commits that are already well-written and descriptive, avoid rewording them. Simply capitalize the first letter, fix any misspellings, and ensure proper English grammar.
|
||||
- DO NOT read any other commits than the ones listed above (THIS IS IMPORTANT TO AVOID DUPLICATING THINGS IN OUR CHANGELOG)
|
||||
- If a commit was made and then reverted do not include it in the changelog. If the commits only include a revert but not the original commit, then include the revert in the changelog.
|
||||
|
||||
IMPORTANT: ONLY return a bulleted list of changes, do not include any other information. Do not include a preamble like "Based on my analysis..."
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.116",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user