mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 03:19:59 -04:00
Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29b3e40ddb | |||
| c49f5939a2 | |||
| 63862b1609 | |||
| 1cf1e88b52 | |||
| d06afd87e5 | |||
| 9fb6e81007 | |||
| 3ac82227f1 | |||
| c1f9249c84 | |||
| 9bb66946db | |||
| adcdbbddc7 | |||
| 662435c5bb | |||
| 36c1a05eaa | |||
| 5708e3bf1e | |||
| 0da1ed3fc8 | |||
| d5179c8b63 | |||
| bd0a4f7bbe | |||
| 3d43214075 | |||
| 178a14ce3e | |||
| 8e1010dc3f | |||
| 9c82f1f5e9 | |||
| e5a651eef7 | |||
| d26605aa56 | |||
| 5cc0d337b1 | |||
| 902763b47d | |||
| 55d07a139c | |||
| 05232ead93 | |||
| 7652a96064 | |||
| 901aae09f7 | |||
| f95799f17c | |||
| 99a6c5e44d | |||
| 07bb75f086 | |||
| 66eb846e6f | |||
| 34f11c699e | |||
| 7a32fec008 | |||
| 37a6b5177e | |||
| 573ffe186b | |||
| 0f7ff3fcb1 | |||
| 2c3aa330b9 | |||
| 47b2fb79dc | |||
| 6deaf54bb3 | |||
| d549cd3213 | |||
| 93e52f7ecf | |||
| 88f12b0822 | |||
| 54af7f9e18 | |||
| be685e95a3 | |||
| dc2ab75fca | |||
| f1324e886f | |||
| c47fde2ca4 | |||
| f42e1c6375 | |||
| f68374ad22 | |||
| 5e86c9b791 | |||
| 94658c31c5 | |||
| 9fd672a1cb | |||
| 10523c4372 | |||
| d1cd7d0344 | |||
| 06ac1be226 | |||
| 05489bc843 | |||
| 3f02eecf22 | |||
| f5ca78ed7b | |||
| 894cbaa51e | |||
| 8b70b89fde | |||
| f9dbc586dc | |||
| ffeef63ca1 | |||
| 4da58294d9 | |||
| fa2e88f49b | |||
| 28e765ef0a | |||
| bfbcb5f200 | |||
| 89492b3002 | |||
| 2663415d47 | |||
| 51be67cc14 | |||
| 92a1943771 | |||
| 1e15fc273a | |||
| 104a895a71 | |||
| f98e730405 | |||
| b12bef05d3 | |||
| 2f1d001cc5 | |||
| 65d0b3ed6d | |||
| 22a34d7958 | |||
| cb4401ec92 | |||
| febf467b03 | |||
| d55a2fd56c | |||
| 40f577e5e7 | |||
| 9e49870118 | |||
| fe38e3ab02 | |||
| 0170577743 | |||
| 7de6ea5922 | |||
| 2fe7d13e69 | |||
| 1bc3c98ae7 | |||
| 55787f2caa | |||
| 7df61a74a0 | |||
| 4f23110880 | |||
| 041353f4ff | |||
| c72f8b17c6 | |||
| eb304f4115 | |||
| 5565f14ef5 | |||
| 10a4455c6f | |||
| 5ded6d6ad7 | |||
| 849a38c30c | |||
| 68050ab802 | |||
| 91d01fd4cc | |||
| 9beb0f8512 | |||
| d4cb47eadc | |||
| 261ff416a9 | |||
| d0a70cb217 | |||
| 20fc56d020 | |||
| a57ae3ec93 | |||
| 30f9fa12d9 | |||
| d473d4ffc8 | |||
| af50596529 | |||
| 3823d8d50e | |||
| 7a926b32ce | |||
| a5ede68241 | |||
| 60dc38050d | |||
| 31d0caee38 | |||
| 2a7ab45605 | |||
| 019054dd1e | |||
| a018a15f32 | |||
| e630d680dd | |||
| 9e392f25a6 | |||
| 2cc4e6ad7c | |||
| 70d8d1ab1e | |||
| 342aa27e03 | |||
| e1aed0cd01 | |||
| c8ea2c5ce0 | |||
| 5e8309a353 | |||
| aae0ce9921 | |||
| 81b94d84dc | |||
| ceab70f8d9 | |||
| afe8cecc2b | |||
| 4a292bf977 | |||
| e249b41513 | |||
| 9021dd60a1 | |||
| b9a39b816c | |||
| 1eeba770b1 | |||
| 6cff306be1 | |||
| 96bdeb3c7b | |||
| 81c617770d | |||
| 021334509e | |||
| 4bde3f7b15 | |||
| 4355027408 | |||
| b022cf0ed6 | |||
| a529b0324d | |||
| 16f5e16395 | |||
| 76e080b2cb | |||
| ffc889b99e | |||
| 36b48a44ac | |||
| 5379abe330 | |||
| a5bcb76bbf | |||
| b628c580c2 | |||
| 46d675b980 | |||
| a8bf1ad40f | |||
| 0ac943de90 | |||
| 485135cf5c | |||
| 543eee78a6 | |||
| dafb63cfb3 |
@@ -5,6 +5,8 @@ runs:
|
||||
steps:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: package.json
|
||||
|
||||
- name: Cache ~/.bun
|
||||
id: cache-bun
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Auto-label TUI Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
auto-label:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Auto-label and assign issues
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = issue.title;
|
||||
const description = issue.body || '';
|
||||
|
||||
// Check for web/desktop keywords
|
||||
const webPattern = /\b(web|desktop)\b/i;
|
||||
const isWebRelated = webPattern.test(title) || webPattern.test(description);
|
||||
|
||||
// Check for version patterns like v1.0.x or 1.0.x
|
||||
const versionPattern = /[v]?1\.0\./i;
|
||||
const isVersionRelated = versionPattern.test(title) || versionPattern.test(description);
|
||||
|
||||
if (isWebRelated) {
|
||||
// Add web label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['web']
|
||||
});
|
||||
|
||||
// Assign to adamdotdevin
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
assignees: ['adamdotdevin']
|
||||
});
|
||||
} else if (isVersionRelated) {
|
||||
// Only add opentui if NOT web-related
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['opentui']
|
||||
});
|
||||
}
|
||||
@@ -26,4 +26,4 @@ jobs:
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
with:
|
||||
model: opencode/kimi-k2
|
||||
model: opencode/glm-4.6
|
||||
|
||||
@@ -5,6 +5,7 @@ node_modules
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
*~
|
||||
openapi.json
|
||||
playground
|
||||
tmp
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
#!/bin/sh
|
||||
bun run typecheck
|
||||
bun typecheck
|
||||
|
||||
@@ -18,3 +18,6 @@ For anything in the packages/app use the ignore: prefix.
|
||||
|
||||
prefer to explain WHY something was done from an end user perspective instead of
|
||||
WHAT was done.
|
||||
|
||||
do not do generic messages like "improved agent experience" be very specific
|
||||
about what user facing changes were made
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: hello world
|
||||
description: hello world iaosd ioasjdoiasjd oisadjoisajd osiajd oisaj dosaij dsoajsajdaijdoisa jdoias jdoias jdoia jois jo jdois jdoias jdoias j djoasdj
|
||||
---
|
||||
|
||||
hey there $ARGUMENTS
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
## Tool Calling
|
||||
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environnement:
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environment:
|
||||
|
||||
json
|
||||
{
|
||||
|
||||
+3
-3
@@ -26,7 +26,7 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
|
||||
|
||||
## Developing OpenCode
|
||||
|
||||
- Requirements: Bun 1.3+, Go 1.24.x.
|
||||
- Requirements: Bun 1.3+
|
||||
- Install dependencies and start the dev server from the repo root:
|
||||
|
||||
```bash
|
||||
@@ -36,11 +36,11 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
|
||||
|
||||
- Core pieces:
|
||||
- `packages/opencode`: OpenCode core business logic & server.
|
||||
- `packages/tui`: The TUI code, written in Go (will be removed soon in favor of [opentui](https://github.com/sst/opentui))
|
||||
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
|
||||
- `packages/plugin`: Source for `@opencode-ai/plugin`
|
||||
|
||||
> [!NOTE]
|
||||
> After touching `packages/opencode/src/server/server.ts`, the OpenCode team must regenerate the Stainless SDK before any client updates merge.
|
||||
> After touching `packages/opencode/src/server/server.ts`, run "./packages/sdk/js/script/build.ts" to regenerate the JS sdk.
|
||||
|
||||
## Pull Request Expectations
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ curl -fsSL https://opencode.ai/install | bash
|
||||
npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
||||
scoop bucket add extras; scoop install extras/opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install sst/tap/opencode # macOS and Linux
|
||||
brew install opencode # macOS and Linux
|
||||
paru -S opencode-bin # Arch Linux
|
||||
```
|
||||
|
||||
|
||||
@@ -125,3 +125,7 @@
|
||||
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
|
||||
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
|
||||
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
|
||||
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
|
||||
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
|
||||
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
|
||||
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
|
||||
|
||||
+48
-15
@@ -152,6 +152,9 @@ try {
|
||||
return session.id.slice(-8)
|
||||
})()
|
||||
console.log("opencode session", session.id)
|
||||
if (shareId) {
|
||||
console.log("Share link:", `${useShareUrl()}/s/${shareId}`)
|
||||
}
|
||||
|
||||
// Handle 3 cases
|
||||
// 1. Issue
|
||||
@@ -168,7 +171,9 @@ try {
|
||||
const summary = await summarize(response)
|
||||
await pushToLocalBranch(summary)
|
||||
}
|
||||
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`))
|
||||
const hasShared = prData.comments.nodes.some((c) =>
|
||||
c.body.includes(`${useShareUrl()}/s/${shareId}`),
|
||||
)
|
||||
await updateComment(`${response}${footer({ image: !hasShared })}`)
|
||||
}
|
||||
// Fork PR
|
||||
@@ -180,7 +185,9 @@ try {
|
||||
const summary = await summarize(response)
|
||||
await pushToForkBranch(summary, prData)
|
||||
}
|
||||
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`))
|
||||
const hasShared = prData.comments.nodes.some((c) =>
|
||||
c.body.includes(`${useShareUrl()}/s/${shareId}`),
|
||||
)
|
||||
await updateComment(`${response}${footer({ image: !hasShared })}`)
|
||||
}
|
||||
}
|
||||
@@ -361,7 +368,9 @@ async function getAccessToken() {
|
||||
|
||||
if (!response.ok) {
|
||||
const responseJson = (await response.json()) as { error?: string }
|
||||
throw new Error(`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`)
|
||||
throw new Error(
|
||||
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`,
|
||||
)
|
||||
}
|
||||
|
||||
const responseJson = (await response.json()) as { token: string }
|
||||
@@ -402,8 +411,12 @@ async function getUserPrompt() {
|
||||
// ie. <img alt="Image" src="https://github.com/user-attachments/assets/xxxx" />
|
||||
// ie. [api.json](https://github.com/user-attachments/files/21433810/api.json)
|
||||
// ie. 
|
||||
const mdMatches = prompt.matchAll(/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi)
|
||||
const tagMatches = prompt.matchAll(/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi)
|
||||
const mdMatches = prompt.matchAll(
|
||||
/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi,
|
||||
)
|
||||
const tagMatches = prompt.matchAll(
|
||||
/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi,
|
||||
)
|
||||
const matches = [...mdMatches, ...tagMatches].sort((a, b) => a.index - b.index)
|
||||
console.log("Images", JSON.stringify(matches, null, 2))
|
||||
|
||||
@@ -430,7 +443,8 @@ async function getUserPrompt() {
|
||||
|
||||
// Replace img tag with file path, ie. @image.png
|
||||
const replacement = `@${filename}`
|
||||
prompt = prompt.slice(0, start + offset) + replacement + prompt.slice(start + offset + tag.length)
|
||||
prompt =
|
||||
prompt.slice(0, start + offset) + replacement + prompt.slice(start + offset + tag.length)
|
||||
offset += replacement.length - tag.length
|
||||
|
||||
const contentType = res.headers.get("content-type")
|
||||
@@ -498,7 +512,12 @@ async function subscribeSessionEvents() {
|
||||
? JSON.stringify(part.state.input)
|
||||
: "Unknown"
|
||||
console.log()
|
||||
console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title)
|
||||
console.log(
|
||||
color + `|`,
|
||||
"\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`,
|
||||
"",
|
||||
"\x1b[0m" + title,
|
||||
)
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
@@ -710,7 +729,8 @@ async function assertPermissions() {
|
||||
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
|
||||
}
|
||||
|
||||
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
|
||||
if (!["admin", "write"].includes(permission))
|
||||
throw new Error(`User ${actor} does not have write permissions`)
|
||||
}
|
||||
|
||||
async function updateComment(body: string) {
|
||||
@@ -730,12 +750,13 @@ async function updateComment(body: string) {
|
||||
async function createPR(base: string, branch: string, title: string, body: string) {
|
||||
console.log("Creating pull request...")
|
||||
const { repo } = useContext()
|
||||
const truncatedTitle = title.length > 256 ? title.slice(0, 253) + "..." : title
|
||||
const pr = await octoRest.rest.pulls.create({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
head: branch,
|
||||
base,
|
||||
title,
|
||||
title: truncatedTitle,
|
||||
body,
|
||||
})
|
||||
return pr.data.number
|
||||
@@ -753,7 +774,9 @@ function footer(opts?: { image?: boolean }) {
|
||||
|
||||
return `<a href="${useShareUrl()}/s/${shareId}"><img width="200" alt="${titleAlt}" src="https://social-cards.sst.dev/opencode-share/${title64}.png?model=${providerID}/${modelID}&version=${session.version}&id=${shareId}" /></a>\n`
|
||||
})()
|
||||
const shareUrl = shareId ? `[opencode session](${useShareUrl()}/s/${shareId}) | ` : ""
|
||||
const shareUrl = shareId
|
||||
? `[opencode session](${useShareUrl()}/s/${shareId}) | `
|
||||
: ""
|
||||
return `\n\n${image}${shareUrl}[github run](${useEnvRunUrl()})`
|
||||
}
|
||||
|
||||
@@ -936,9 +959,13 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
|
||||
})
|
||||
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
|
||||
|
||||
const files = (pr.files.nodes || []).map((f) => `- ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`)
|
||||
const files = (pr.files.nodes || []).map(
|
||||
(f) => `- ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`,
|
||||
)
|
||||
const reviewData = (pr.reviews.nodes || []).map((r) => {
|
||||
const comments = (r.comments.nodes || []).map((c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`)
|
||||
const comments = (r.comments.nodes || []).map(
|
||||
(c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`,
|
||||
)
|
||||
return [
|
||||
`- ${r.author.login} at ${r.submittedAt}:`,
|
||||
` - Review body: ${r.body}`,
|
||||
@@ -960,9 +987,15 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
|
||||
`Deletions: ${pr.deletions}`,
|
||||
`Total Commits: ${pr.commits.totalCount}`,
|
||||
`Changed Files: ${pr.files.nodes.length} files`,
|
||||
...(comments.length > 0 ? ["<pull_request_comments>", ...comments, "</pull_request_comments>"] : []),
|
||||
...(files.length > 0 ? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"] : []),
|
||||
...(reviewData.length > 0 ? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"] : []),
|
||||
...(comments.length > 0
|
||||
? ["<pull_request_comments>", ...comments, "</pull_request_comments>"]
|
||||
: []),
|
||||
...(files.length > 0
|
||||
? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"]
|
||||
: []),
|
||||
...(reviewData.length > 0
|
||||
? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"]
|
||||
: []),
|
||||
"</pull_request>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
+11
-3
@@ -61,7 +61,13 @@ export const auth = new sst.cloudflare.Worker("AuthApi", {
|
||||
domain: `auth.${domain}`,
|
||||
handler: "packages/console/function/src/auth.ts",
|
||||
url: true,
|
||||
link: [database, authStorage, GITHUB_CLIENT_ID_CONSOLE, GITHUB_CLIENT_SECRET_CONSOLE, GOOGLE_CLIENT_ID],
|
||||
link: [
|
||||
database,
|
||||
authStorage,
|
||||
GITHUB_CLIENT_ID_CONSOLE,
|
||||
GITHUB_CLIENT_SECRET_CONSOLE,
|
||||
GOOGLE_CLIENT_ID,
|
||||
],
|
||||
})
|
||||
|
||||
////////////////
|
||||
@@ -97,7 +103,8 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
||||
],
|
||||
})
|
||||
|
||||
const ZEN_MODELS = new sst.Secret("ZEN_MODELS")
|
||||
const ZEN_MODELS1 = new sst.Secret("ZEN_MODELS1")
|
||||
const ZEN_MODELS2 = new sst.Secret("ZEN_MODELS2")
|
||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||
properties: { value: auth.url.apply((url) => url!) },
|
||||
@@ -130,7 +137,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
AUTH_API_URL,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
STRIPE_SECRET_KEY,
|
||||
ZEN_MODELS,
|
||||
ZEN_MODELS1,
|
||||
ZEN_MODELS2,
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
|
||||
@@ -10,10 +10,14 @@ NC='\033[0m' # No Color
|
||||
|
||||
requested_version=${VERSION:-}
|
||||
|
||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$os" == "darwin" ]]; then
|
||||
os="darwin"
|
||||
fi
|
||||
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)
|
||||
|
||||
if [[ "$arch" == "aarch64" ]]; then
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": true,
|
||||
"amount": 14
|
||||
},
|
||||
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1759827172859,
|
||||
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
|
||||
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.879"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.880"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.191"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.192"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.267"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.268"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.276"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.277"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.838"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.839"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.499"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.500"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
||||
{"arguments":{"url":"https://google.com"},"level":"debug","message":"Tool call received","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150","tool":"puppeteer_navigate"}
|
||||
{"0":"n","1":"p","2":"x","level":"info","message":"Launching browser with config:","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.488"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.489"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.815"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.816"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.934"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.935"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.154"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.155"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.426"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.427"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.715"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.716"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.063"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.064"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.567"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.568"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.937"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.938"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.120"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.121"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.490"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.491"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.524"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.525"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.126"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.127"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.175"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.176"}
|
||||
+15
-1
@@ -1,3 +1,17 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": ["opencode-openai-codex-auth"],
|
||||
"mcp": {
|
||||
"weather": {
|
||||
"type": "local",
|
||||
"command": ["bun", "x", "@h1deya/mcp-server-weather"]
|
||||
},
|
||||
"context7": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"headers": {
|
||||
"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.0",
|
||||
"packageManager": "bun@1.3.1",
|
||||
"scripts": {
|
||||
"dev": "bun run packages/opencode/src/index.ts",
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
@@ -19,6 +21,7 @@
|
||||
"catalog": {
|
||||
"@types/bun": "1.3.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/node": "22.13.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
|
||||
@@ -7,13 +7,15 @@
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"start": "vinxi start",
|
||||
"version": "0.15.30"
|
||||
"version": "1.0.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
"@opencode-ai/console-mail": "workspace:*",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
"@opencode-ai/console-resource": "workspace:*",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.15.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createAsync } from "@solidjs/router"
|
||||
import { createMemo } from "solid-js"
|
||||
import { github } from "~/lib/github"
|
||||
import { config } from "~/config"
|
||||
|
||||
export function Footer() {
|
||||
const githubData = createAsync(() => github())
|
||||
@@ -10,13 +11,13 @@ export function Footer() {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(githubData()!.stars!)
|
||||
: "25K",
|
||||
: config.github.starsFormatted.compact,
|
||||
)
|
||||
|
||||
return (
|
||||
<footer data-component="footer">
|
||||
<div data-slot="cell">
|
||||
<a href="https://github.com/sst/opencode" target="_blank">
|
||||
<a href={config.github.repoUrl} target="_blank">
|
||||
GitHub <span>[{starCount()}]</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -27,7 +28,7 @@ export function Footer() {
|
||||
<a href="/discord">Discord</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<a href="https://x.com/opencode">X</a>
|
||||
<a href={config.social.twitter}>X</a>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { github } from "~/lib/github"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { config } from "~/config"
|
||||
import "./header-context-menu.css"
|
||||
|
||||
const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
@@ -42,7 +43,7 @@ export function Header(props: { zen?: boolean }) {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(githubData()?.stars!)
|
||||
: "29K",
|
||||
: config.github.starsFormatted.compact,
|
||||
)
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
@@ -148,7 +149,7 @@ export function Header(props: { zen?: boolean }) {
|
||||
<nav data-component="nav-desktop">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/sst/opencode" target="_blank">
|
||||
<a href={config.github.repoUrl} target="_blank">
|
||||
GitHub <span>[{starCount()}]</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -222,7 +223,7 @@ export function Header(props: { zen?: boolean }) {
|
||||
<A href="/">Home</A>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/sst/opencode" target="_blank">
|
||||
<a href={config.github.repoUrl} target="_blank">
|
||||
GitHub <span>[{starCount()}]</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Application-wide constants and configuration
|
||||
*/
|
||||
export const config = {
|
||||
// GitHub
|
||||
github: {
|
||||
repoUrl: "https://github.com/sst/opencode",
|
||||
starsFormatted: {
|
||||
compact: "30K",
|
||||
full: "30,000",
|
||||
},
|
||||
},
|
||||
|
||||
// Social links
|
||||
social: {
|
||||
twitter: "https://x.com/opencode",
|
||||
discord: "https://discord.gg/opencode",
|
||||
},
|
||||
|
||||
// Static stats (used on landing page)
|
||||
stats: {
|
||||
contributors: "250",
|
||||
commits: "3,500",
|
||||
monthlyUsers: "300,000",
|
||||
},
|
||||
} as const
|
||||
@@ -1,4 +1,5 @@
|
||||
import { query } from "@solidjs/router"
|
||||
import { config } from "~/config"
|
||||
|
||||
export const github = query(async () => {
|
||||
"use server"
|
||||
@@ -6,11 +7,15 @@ export const github = query(async () => {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
||||
}
|
||||
const apiBaseUrl = config.github.repoUrl.replace(
|
||||
"https://github.com/",
|
||||
"https://api.github.com/repos/",
|
||||
)
|
||||
try {
|
||||
const [meta, releases, contributors] = await Promise.all([
|
||||
fetch("https://api.github.com/repos/sst/opencode", { headers }).then((res) => res.json()),
|
||||
fetch("https://api.github.com/repos/sst/opencode/releases", { headers }).then((res) => res.json()),
|
||||
fetch("https://api.github.com/repos/sst/opencode/contributors?per_page=1", { headers }),
|
||||
fetch(apiBaseUrl, { headers }).then((res) => res.json()),
|
||||
fetch(`${apiBaseUrl}/releases`, { headers }).then((res) => res.json()),
|
||||
fetch(`${apiBaseUrl}/contributors?per_page=1`, { headers }),
|
||||
])
|
||||
const [release] = releases
|
||||
const contributorCount = Number.parseInt(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,8 @@ export async function POST(input: APIEvent) {
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
const customer = await Billing.get()
|
||||
if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
|
||||
if (customer?.customerID && customer.customerID !== customerID)
|
||||
throw new Error("Customer ID mismatch")
|
||||
|
||||
// set customer metadata
|
||||
if (!customer?.customerID) {
|
||||
@@ -69,7 +70,18 @@ export async function POST(input: APIEvent) {
|
||||
expand: ["payment_method"],
|
||||
})
|
||||
const paymentMethod = paymentIntent.payment_method
|
||||
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
|
||||
if (!paymentMethod || typeof paymentMethod === "string")
|
||||
throw new Error("Payment method not expanded")
|
||||
|
||||
const oldBillingInfo = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
customerID: BillingTable.customerID,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
@@ -80,9 +92,14 @@ export async function POST(input: APIEvent) {
|
||||
paymentMethodID: paymentMethod.id,
|
||||
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
||||
paymentMethodType: paymentMethod.type,
|
||||
reload: true,
|
||||
reloadError: null,
|
||||
timeReloadError: null,
|
||||
// enable reload if first time enabling billing
|
||||
...(oldBillingInfo?.customerID
|
||||
? {}
|
||||
: {
|
||||
reload: true,
|
||||
reloadError: null,
|
||||
timeReloadError: null,
|
||||
}),
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
await tx.insert(PaymentTable).values({
|
||||
@@ -119,7 +136,12 @@ export async function POST(input: APIEvent) {
|
||||
.set({
|
||||
timeRefunded: new Date(body.created * 1000),
|
||||
})
|
||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||
.where(
|
||||
and(
|
||||
eq(PaymentTable.paymentID, paymentIntentID),
|
||||
eq(PaymentTable.workspaceID, workspaceID),
|
||||
),
|
||||
)
|
||||
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
p {
|
||||
color: var(--color-danger);
|
||||
@@ -24,27 +21,65 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="payment"] {
|
||||
[data-slot="section-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
min-width: 14.5rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
[data-slot="balance-display"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="balance-amount"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg-surface);
|
||||
align-self: stretch;
|
||||
|
||||
[data-slot="balance-label"] {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin-top: var(--space-2);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-slot="balance-value"] {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="balance-right-section"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-slot="credit-card"] {
|
||||
padding: var(--space-3-5) var(--space-4);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background-color: var(--color-bg-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
min-width: 150px;
|
||||
align-self: flex-start;
|
||||
|
||||
[data-slot="card-icon"] {
|
||||
display: flex;
|
||||
@@ -56,19 +91,19 @@
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
|
||||
[data-slot="secret"] {
|
||||
position: relative;
|
||||
bottom: 2px;
|
||||
font-size: var(--font-size-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-slot="number"] {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="type"] {
|
||||
@@ -77,41 +112,23 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="button-row"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
flex-direction: column;
|
||||
|
||||
>button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Make Enable Billing button full width when it's the only button */
|
||||
>button {
|
||||
flex: 1;
|
||||
}
|
||||
button {
|
||||
align-self: flex-start;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="usage"] {
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
b {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
[data-slot="enable-billing-button"] {
|
||||
align-self: flex-start;
|
||||
padding: var(--space-4);
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,24 @@
|
||||
import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { IconCreditCard, IconStripe } from "~/component/icon"
|
||||
import styles from "./billing-section.module.css"
|
||||
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { createCheckoutUrl } from "../../common"
|
||||
|
||||
const reload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key })
|
||||
}, "billing.reload")
|
||||
|
||||
const setReload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
const reload = form.get("reload")?.toString() === "true"
|
||||
return json(
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
reload,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||
),
|
||||
{ revalidate: getBillingInfo.key },
|
||||
)
|
||||
}, "billing.setReload")
|
||||
import { createCheckoutUrl, queryBillingInfo } from "../../common"
|
||||
|
||||
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||
"use server"
|
||||
return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
|
||||
}, "sessionUrl")
|
||||
|
||||
const getBillingInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
return await Billing.get()
|
||||
}, workspaceID)
|
||||
}, "billing.get")
|
||||
|
||||
export function BillingSection() {
|
||||
const params = useParams()
|
||||
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
|
||||
const balanceInfo = createAsync(() => getBillingInfo(params.id))
|
||||
const balanceInfo = createAsync(() => queryBillingInfo(params.id))
|
||||
const createCheckoutUrlAction = useAction(createCheckoutUrl)
|
||||
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
|
||||
const createSessionUrlAction = useAction(createSessionUrl)
|
||||
const createSessionUrlSubmission = useSubmission(createSessionUrl)
|
||||
const setReloadSubmission = useSubmission(setReload)
|
||||
const reloadSubmission = useSubmission(reload)
|
||||
|
||||
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
|
||||
|
||||
@@ -112,143 +76,95 @@ export function BillingSection() {
|
||||
return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
|
||||
})
|
||||
|
||||
const hasBalance = createMemo(() => {
|
||||
return (balanceInfo()?.balance ?? 0) > 0 && balanceAmount() !== "0.00"
|
||||
})
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Billing</h2>
|
||||
<p>
|
||||
Manage payments methods. <a href="mailto:contact@anoma.ly">Contact us</a> if you have any questions.
|
||||
Manage payments methods. <a href="mailto:contact@anoma.ly">Contact us</a> if you have any
|
||||
questions.
|
||||
</p>
|
||||
</div>
|
||||
<div data-slot="section-content">
|
||||
<Show when={balanceInfo()?.reloadError}>
|
||||
<div data-slot="reload-error">
|
||||
<p>
|
||||
Reload failed at{" "}
|
||||
{balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
. Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
|
||||
again.
|
||||
</p>
|
||||
<form action={reload} method="post" data-slot="create-form">
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<button data-color="primary" type="submit" disabled={reloadSubmission.pending}>
|
||||
{reloadSubmission.pending ? "Reloading..." : "Reload"}
|
||||
</button>
|
||||
</form>
|
||||
<div data-slot="balance-display">
|
||||
<div data-slot="balance-amount">
|
||||
<span data-slot="balance-value">
|
||||
${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}
|
||||
</span>
|
||||
<span data-slot="balance-label">Current Balance</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="payment">
|
||||
<div data-slot="credit-card">
|
||||
<div data-slot="card-icon">
|
||||
<Switch fallback={<IconCreditCard style={{ width: "32px", height: "32px" }} />}>
|
||||
<Match when={balanceInfo()?.paymentMethodType === "link"}>
|
||||
<IconStripe style={{ width: "32px", height: "32px" }} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div data-slot="card-details">
|
||||
<Switch
|
||||
fallback={
|
||||
<Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
|
||||
<span data-slot="secret">••••</span>
|
||||
<span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Match when={balanceInfo()?.paymentMethodType === "link"}>
|
||||
<span data-slot="type">Linked to Stripe</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="button-row">
|
||||
<Show
|
||||
when={balanceInfo()?.reload}
|
||||
fallback={
|
||||
<Show
|
||||
when={hasBalance()}
|
||||
fallback={
|
||||
<button
|
||||
data-color="primary"
|
||||
disabled={createCheckoutUrlSubmission.pending}
|
||||
onClick={async () => {
|
||||
const baseUrl = window.location.href
|
||||
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
|
||||
if (checkoutUrl) {
|
||||
window.location.href = checkoutUrl
|
||||
}
|
||||
}}
|
||||
>
|
||||
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form action={setReload} method="post" data-slot="create-form">
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<input type="hidden" name="reload" value="true" />
|
||||
<button data-color="primary" type="submit" disabled={setReloadSubmission.pending}>
|
||||
{setReloadSubmission.pending ? "Enabling..." : "Enable Billing"}
|
||||
</button>
|
||||
</form>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show when={balanceInfo()?.customerID}>
|
||||
<div data-slot="balance-right-section">
|
||||
<button
|
||||
data-color="primary"
|
||||
disabled={createSessionUrlSubmission.pending}
|
||||
disabled={createCheckoutUrlSubmission.pending}
|
||||
onClick={async () => {
|
||||
const baseUrl = window.location.href
|
||||
const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
|
||||
if (sessionUrl) {
|
||||
window.location.href = sessionUrl
|
||||
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
|
||||
if (checkoutUrl) {
|
||||
window.location.href = checkoutUrl
|
||||
}
|
||||
}}
|
||||
>
|
||||
{createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
|
||||
{createCheckoutUrlSubmission.pending ? "Loading..." : "Add Balance"}
|
||||
</button>
|
||||
<form action={setReload} method="post" data-slot="create-form">
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<input type="hidden" name="reload" value="false" />
|
||||
<button data-color="ghost" type="submit" disabled={setReloadSubmission.pending}>
|
||||
{setReloadSubmission.pending ? "Disabling..." : "Disable"}
|
||||
<div data-slot="credit-card">
|
||||
<div data-slot="card-icon">
|
||||
<Switch fallback={<IconCreditCard style={{ width: "24px", height: "24px" }} />}>
|
||||
<Match when={balanceInfo()?.paymentMethodType === "link"}>
|
||||
<IconStripe style={{ width: "24px", height: "24px" }} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div data-slot="card-details">
|
||||
<Switch>
|
||||
<Match when={balanceInfo()?.paymentMethodType === "card"}>
|
||||
<Show
|
||||
when={balanceInfo()?.paymentMethodLast4}
|
||||
fallback={<span data-slot="number">----</span>}
|
||||
>
|
||||
<span data-slot="secret">••••</span>
|
||||
<span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={balanceInfo()?.paymentMethodType === "link"}>
|
||||
<span data-slot="type">Linked to Stripe</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<button
|
||||
data-color="ghost"
|
||||
disabled={createSessionUrlSubmission.pending}
|
||||
onClick={async () => {
|
||||
const baseUrl = window.location.href
|
||||
const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
|
||||
if (sessionUrl) {
|
||||
window.location.href = sessionUrl
|
||||
}
|
||||
}}
|
||||
>
|
||||
{createSessionUrlSubmission.pending ? "Loading..." : "Manage"}
|
||||
</button>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="usage">
|
||||
<Show when={!balanceInfo()?.reload}>
|
||||
<Show
|
||||
when={hasBalance()}
|
||||
fallback={
|
||||
<p>
|
||||
We'll load <b>$20</b> (+$1.23 processing fee) and reload it when it reaches <b>$5</b>.
|
||||
</p>
|
||||
<Show when={!balanceInfo()?.customerID}>
|
||||
<button
|
||||
data-slot="enable-billing-button"
|
||||
data-color="primary"
|
||||
disabled={createCheckoutUrlSubmission.pending}
|
||||
onClick={async () => {
|
||||
const baseUrl = window.location.href
|
||||
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
|
||||
if (checkoutUrl) {
|
||||
window.location.href = checkoutUrl
|
||||
}
|
||||
>
|
||||
<p>
|
||||
You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
|
||||
your account. You can continue using the API with your remaining balance.
|
||||
</p>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
|
||||
<p>
|
||||
Your current balance is <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
|
||||
. We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches <b>$5</b>.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
}}
|
||||
>
|
||||
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { MonthlyLimitSection } from "./monthly-limit-section"
|
||||
import { BillingSection } from "./billing-section"
|
||||
import { ReloadSection } from "./reload-section"
|
||||
import { PaymentSection } from "./payment-section"
|
||||
import { Show } from "solid-js"
|
||||
import { createAsync, useParams } from "@solidjs/router"
|
||||
import { querySessionInfo } from "../../common"
|
||||
import { queryBillingInfo, querySessionInfo } from "../../common"
|
||||
|
||||
export default function () {
|
||||
const params = useParams()
|
||||
const userInfo = createAsync(() => querySessionInfo(params.id))
|
||||
const billingInfo = createAsync(() => queryBillingInfo(params.id))
|
||||
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<div data-slot="sections">
|
||||
<Show when={userInfo()?.isAdmin}>
|
||||
<BillingSection />
|
||||
<MonthlyLimitSection />
|
||||
<PaymentSection />
|
||||
<Show when={billingInfo()?.customerID}>
|
||||
<ReloadSection />
|
||||
<MonthlyLimitSection />
|
||||
<PaymentSection />
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
.root {
|
||||
[data-slot="title-row"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
[data-slot="section-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
[data-slot="setting-row"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
p {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
|
||||
b {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="reload-error"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-4);
|
||||
|
||||
p {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { Show } from "solid-js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import styles from "./reload-section.module.css"
|
||||
|
||||
const reload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
return json(await withActor(() => Billing.reload(), workspaceID), {
|
||||
revalidate: getBillingInfo.key,
|
||||
})
|
||||
}, "billing.reload")
|
||||
|
||||
const setReload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
const reloadValue = form.get("reload")?.toString() === "true"
|
||||
return json(
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
reload: reloadValue,
|
||||
...(reloadValue
|
||||
? {
|
||||
reloadError: null,
|
||||
timeReloadError: null,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||
),
|
||||
{ revalidate: getBillingInfo.key },
|
||||
)
|
||||
}, "billing.setReload")
|
||||
|
||||
const getBillingInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
return await Billing.get()
|
||||
}, workspaceID)
|
||||
}, "billing.get")
|
||||
|
||||
export function ReloadSection() {
|
||||
const params = useParams()
|
||||
const balanceInfo = createAsync(() => getBillingInfo(params.id))
|
||||
const setReloadSubmission = useSubmission(setReload)
|
||||
const reloadSubmission = useSubmission(reload)
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Auto Reload</h2>
|
||||
<div data-slot="title-row">
|
||||
<Show
|
||||
when={balanceInfo()?.reload}
|
||||
fallback={
|
||||
<p>Auto reload is disabled. Enable to automatically reload when balance is low.</p>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches{" "}
|
||||
<b>$5</b>.
|
||||
</p>
|
||||
</Show>
|
||||
<form action={setReload} method="post" data-slot="create-form">
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<input type="hidden" name="reload" value={balanceInfo()?.reload ? "false" : "true"} />
|
||||
<button data-color="primary" type="submit" disabled={setReloadSubmission.pending}>
|
||||
<Show
|
||||
when={balanceInfo()?.reload}
|
||||
fallback={setReloadSubmission.pending ? "Enabling..." : "Enable"}
|
||||
>
|
||||
{setReloadSubmission.pending ? "Disabling..." : "Disable"}
|
||||
</Show>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="section-content">
|
||||
<Show when={balanceInfo()?.reload && balanceInfo()?.reloadError}>
|
||||
<div data-slot="reload-error">
|
||||
<p>
|
||||
Reload failed at{" "}
|
||||
{balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
. Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment
|
||||
method and try again.
|
||||
</p>
|
||||
<form action={reload} method="post" data-slot="create-form">
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<button data-color="ghost" type="submit" disabled={reloadSubmission.pending}>
|
||||
{reloadSubmission.pending ? "Retrying..." : "Retry"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,15 @@ import { withActor } from "~/context/auth.withActor"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
import styles from "./model-section.module.css"
|
||||
import { querySessionInfo } from "../common"
|
||||
import { IconAlibaba, IconAnthropic, IconMoonshotAI, IconOpenAI, IconStealth, IconXai, IconZai } from "~/component/icon"
|
||||
import {
|
||||
IconAlibaba,
|
||||
IconAnthropic,
|
||||
IconMoonshotAI,
|
||||
IconOpenAI,
|
||||
IconStealth,
|
||||
IconXai,
|
||||
IconZai,
|
||||
} from "~/component/icon"
|
||||
|
||||
const getModelLab = (modelId: string) => {
|
||||
if (modelId.startsWith("claude")) return "Anthropic"
|
||||
@@ -22,8 +30,7 @@ const getModelsInfo = query(async (workspaceID: string) => {
|
||||
return withActor(async () => {
|
||||
return {
|
||||
all: Object.entries(ZenData.list().models)
|
||||
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
|
||||
.filter(([id, _model]) => !id.startsWith("an-"))
|
||||
.filter(([id, _model]) => !["claude-3-5-haiku", "minimax-m2"].includes(id))
|
||||
.sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name))
|
||||
.map(([id, model]) => ({ id, name: model.name })),
|
||||
disabled: await Model.listDisabled(),
|
||||
@@ -68,7 +75,8 @@ export function ModelSection() {
|
||||
<div data-slot="section-title">
|
||||
<h2>Models</h2>
|
||||
<p>
|
||||
Manage which models workspace members can access. <a href="/docs/zen#pricing ">Learn more</a>.
|
||||
Manage which models workspace members can access.{" "}
|
||||
<a href="/docs/zen#pricing ">Learn more</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div data-slot="models-list">
|
||||
|
||||
@@ -62,15 +62,21 @@ export const querySessionInfo = query(async (workspaceID: string) => {
|
||||
return withActor(() => {
|
||||
return {
|
||||
isAdmin: Actor.userRole() === "admin",
|
||||
isBeta: Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true,
|
||||
isBeta:
|
||||
Resource.App.stage === "production"
|
||||
? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y"
|
||||
: true,
|
||||
}
|
||||
}, workspaceID)
|
||||
}, "session.get")
|
||||
|
||||
export const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
|
||||
"use server"
|
||||
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
|
||||
}, "checkoutUrl")
|
||||
export const createCheckoutUrl = action(
|
||||
async (workspaceID: string, successUrl: string, cancelUrl: string) => {
|
||||
"use server"
|
||||
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
|
||||
},
|
||||
"checkoutUrl",
|
||||
)
|
||||
|
||||
export const queryBillingInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type Format = "anthropic" | "openai" | "oa-compat"
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
createStreamPartConverter,
|
||||
createResponseConverter,
|
||||
} from "./provider/provider"
|
||||
import { Format } from "./format"
|
||||
import { anthropicHelper } from "./provider/anthropic"
|
||||
import { openaiHelper } from "./provider/openai"
|
||||
import { oaCompatHelper } from "./provider/openai-compatible"
|
||||
@@ -29,7 +28,7 @@ type Model = ZenData["models"][string]
|
||||
export async function handler(
|
||||
input: APIEvent,
|
||||
opts: {
|
||||
format: Format
|
||||
format: ZenData.Format
|
||||
parseApiKey: (headers: Headers) => string | undefined
|
||||
},
|
||||
) {
|
||||
@@ -239,21 +238,23 @@ export async function handler(
|
||||
.filter((provider) => !provider.disabled)
|
||||
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
||||
|
||||
// Use last character of IP address to select a provider
|
||||
const lastChar = ip.charCodeAt(ip.length - 1) || 0
|
||||
const index = lastChar % providers.length
|
||||
const provider = providers[index]
|
||||
// Use the last 2 characters of IP address to select a provider
|
||||
const lastChars = ip.slice(-2)
|
||||
const index = parseInt(lastChars, 16) % providers.length
|
||||
const provider = providers[index || 0]
|
||||
|
||||
if (!(provider.id in zenData.providers)) {
|
||||
throw new ModelError(`Provider ${provider.id} not supported`)
|
||||
}
|
||||
|
||||
const format = zenData.providers[provider.id].format
|
||||
|
||||
return {
|
||||
...provider,
|
||||
...zenData.providers[provider.id],
|
||||
...(provider.id === "anthropic"
|
||||
...(format === "anthropic"
|
||||
? anthropicHelper
|
||||
: provider.id === "openai"
|
||||
: format === "openai"
|
||||
? openaiHelper
|
||||
: oaCompatHelper),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Format } from "../format"
|
||||
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
import {
|
||||
fromAnthropicChunk,
|
||||
fromAnthropicRequest,
|
||||
@@ -26,7 +25,7 @@ import {
|
||||
} from "./openai-compatible"
|
||||
|
||||
export type ProviderHelper = {
|
||||
format: Format
|
||||
format: ZenData.Format
|
||||
modifyUrl: (providerApi: string) => string
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
|
||||
modifyBody: (body: Record<string, any>) => Record<string, any>
|
||||
@@ -158,7 +157,7 @@ export interface CommonChunk {
|
||||
}
|
||||
}
|
||||
|
||||
export function createBodyConverter(from: Format, to: Format) {
|
||||
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||
return (body: any): any => {
|
||||
if (from === to) return body
|
||||
|
||||
@@ -173,7 +172,7 @@ export function createBodyConverter(from: Format, to: Format) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createStreamPartConverter(from: Format, to: Format) {
|
||||
export function createStreamPartConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||
return (part: any): any => {
|
||||
if (from === to) return part
|
||||
|
||||
@@ -191,7 +190,7 @@ export function createStreamPartConverter(from: Format, to: Format) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createResponseConverter(from: Format, to: Format) {
|
||||
export function createResponseConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||
return (response: any): any => {
|
||||
if (from === to) return response
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `billing` ADD `reload_trigger` int;--> statement-breakpoint
|
||||
ALTER TABLE `billing` ADD `reload_amount` int;
|
||||
@@ -0,0 +1,983 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "8b7fa839-a088-408e-84a4-1a07325c0290",
|
||||
"prevId": "5470c8b4-296d-47bd-85a7-88cfd3b71434",
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,13 @@
|
||||
"when": 1760668952329,
|
||||
"tag": "0036_slimy_energizer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 37,
|
||||
"version": "5",
|
||||
"when": 1761928273807,
|
||||
"tag": "0037_messy_jackal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "0.15.30",
|
||||
"version": "1.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -14,7 +14,7 @@
|
||||
"drizzle-orm": "0.41.0",
|
||||
"postgres": "3.4.7",
|
||||
"stripe": "18.0.0",
|
||||
"ulid": "3.0.0",
|
||||
"ulid": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -11,14 +11,20 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||
const value = ret
|
||||
const value1 = ret
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("ZEN_MODELS"))
|
||||
.find((line) => line.startsWith("ZEN_MODELS1"))
|
||||
?.split("=")[1]
|
||||
if (!value) throw new Error("ZEN_MODELS not found")
|
||||
const value2 = ret
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("ZEN_MODELS2"))
|
||||
?.split("=")[1]
|
||||
if (!value1) throw new Error("ZEN_MODELS1 not found")
|
||||
if (!value2) throw new Error("ZEN_MODELS2 not found")
|
||||
|
||||
// validate value
|
||||
ZenData.validate(JSON.parse(value))
|
||||
ZenData.validate(JSON.parse(value1 + value2))
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_MODELS ${value} --stage ${stage}`
|
||||
await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
|
||||
await $`bun sst secret set ZEN_MODELS2 ${value2} --stage ${stage}`
|
||||
|
||||
@@ -10,23 +10,29 @@ const models = await $`bun sst secret list`.cwd(root).text()
|
||||
console.log("models", models)
|
||||
|
||||
// read the line starting with "ZEN_MODELS"
|
||||
const oldValue = models
|
||||
const oldValue1 = models
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("ZEN_MODELS"))
|
||||
.find((line) => line.startsWith("ZEN_MODELS1"))
|
||||
?.split("=")[1]
|
||||
if (!oldValue) throw new Error("ZEN_MODELS not found")
|
||||
console.log("oldValue", oldValue)
|
||||
const oldValue2 = models
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("ZEN_MODELS2"))
|
||||
?.split("=")[1]
|
||||
if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
|
||||
if (!oldValue2) throw new Error("ZEN_MODELS2 not found")
|
||||
|
||||
// store the prettified json to a temp file
|
||||
const filename = `models-${Date.now()}.json`
|
||||
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
||||
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
|
||||
await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2), null, 2))
|
||||
console.log("tempFile", tempFile.name)
|
||||
|
||||
// open temp file in vim and read the file on close
|
||||
await $`vim ${tempFile.name}`
|
||||
const newValue = JSON.parse(await tempFile.text())
|
||||
ZenData.validate(newValue)
|
||||
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
||||
ZenData.validate(JSON.parse(newValue))
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_MODELS ${JSON.stringify(newValue)}`
|
||||
const mid = Math.floor(newValue.length / 2)
|
||||
await $`bun sst secret set ZEN_MODELS1 ${newValue.slice(0, mid)}`
|
||||
await $`bun sst secret set ZEN_MODELS2 ${newValue.slice(mid)}`
|
||||
|
||||
@@ -13,7 +13,9 @@ export namespace Billing {
|
||||
export const CHARGE_NAME = "opencode credits"
|
||||
export const CHARGE_FEE_NAME = "processing fee"
|
||||
export const CHARGE_AMOUNT = 2000 // $20
|
||||
export const CHARGE_AMOUNT_DOLLAR = 20
|
||||
export const CHARGE_FEE = 123 // Stripe fee 4.4% + $0.30
|
||||
export const CHARGE_THRESHOLD_DOLLAR = 5
|
||||
export const CHARGE_THRESHOLD = 500 // $5
|
||||
export const stripe = () =>
|
||||
new Stripe(Resource.STRIPE_SECRET_KEY.value, {
|
||||
@@ -142,17 +144,6 @@ export namespace Billing {
|
||||
})
|
||||
}
|
||||
|
||||
export const disableReload = async () => {
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
reload: false,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace())),
|
||||
)
|
||||
}
|
||||
|
||||
export const setMonthlyLimit = fn(z.number(), async (input) => {
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
|
||||
@@ -8,6 +8,9 @@ import { Actor } from "./actor"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
export namespace ZenData {
|
||||
const FormatSchema = z.enum(["anthropic", "openai", "oa-compat"])
|
||||
export type Format = z.infer<typeof FormatSchema>
|
||||
|
||||
const ModelCostSchema = z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
@@ -34,6 +37,7 @@ export namespace ZenData {
|
||||
const ProviderSchema = z.object({
|
||||
api: z.string(),
|
||||
apiKey: z.string(),
|
||||
format: FormatSchema,
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
|
||||
@@ -47,7 +51,7 @@ export namespace ZenData {
|
||||
})
|
||||
|
||||
export const list = fn(z.void(), () => {
|
||||
const json = JSON.parse(Resource.ZEN_MODELS.value)
|
||||
const json = JSON.parse(Resource.ZEN_MODELS1.value + Resource.ZEN_MODELS2.value)
|
||||
return ModelsSchema.parse(json)
|
||||
})
|
||||
}
|
||||
@@ -56,7 +60,9 @@ export namespace Model {
|
||||
export const enable = fn(z.object({ model: z.string() }), ({ model }) => {
|
||||
Actor.assertAdmin()
|
||||
return Database.use((db) =>
|
||||
db.delete(ModelTable).where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))),
|
||||
db
|
||||
.delete(ModelTable)
|
||||
.where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export const BillingTable = mysqlTable(
|
||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
|
||||
reload: boolean("reload"),
|
||||
reloadTrigger: int("reload_trigger"),
|
||||
reloadAmount: int("reload_amount"),
|
||||
reloadError: varchar("reload_error", { length: 255 }),
|
||||
timeReloadError: utc("time_reload_error"),
|
||||
timeReloadLockedTill: utc("time_reload_locked_till"),
|
||||
|
||||
Vendored
+96
-1
@@ -3,7 +3,102 @@
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
/// <reference path="../../../sst-env.d.ts" />
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"ADMIN_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AUTH_API_URL": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"AWS_SES_ACCESS_KEY_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AWS_SES_SECRET_ACCESS_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Console": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
}
|
||||
"Database": {
|
||||
"database": string
|
||||
"host": string
|
||||
"password": string
|
||||
"port": number
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"Desktop": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"EMAILOCTOPUS_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_PRIVATE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_ID_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_SECRET_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"HONEYCOMB_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_SECRET_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_WEBHOOK_SECRET": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"Web": {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types";
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": cloudflare.Service
|
||||
"AuthApi": cloudflare.Service
|
||||
"AuthStorage": cloudflare.KVNamespace
|
||||
"Bucket": cloudflare.R2Bucket
|
||||
"LogProcessor": cloudflare.Service
|
||||
}
|
||||
}
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "0.15.30",
|
||||
"version": "1.0.19",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+9
-1
@@ -6,6 +6,10 @@
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"ADMIN_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AUTH_API_URL": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
@@ -74,7 +78,11 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_MODELS": {
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "0.15.30",
|
||||
"version": "1.0.19",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
+9
-1
@@ -6,6 +6,10 @@
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"ADMIN_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AUTH_API_URL": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
@@ -74,7 +78,11 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_MODELS": {
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.svg" />
|
||||
<link rel="shortcut icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>OpenCode</title>
|
||||
</head>
|
||||
<body class="antialiased overscroll-none select-none text-12-regular">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "0.15.30",
|
||||
"version": "1.0.19",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/favicon.svg
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="600" height="600" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M115 180H300V420H115V180ZM253.75 229.044H161.25V370.405H253.75V229.044Z" fill="white"/>
|
||||
<path d="M346.25 180H485V229.044H392.5V370.405H485V419.449H346.25V180Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 377 B |
@@ -90,7 +90,7 @@ export function Code(props: Props) {
|
||||
let ticking = false
|
||||
const onScroll = () => {
|
||||
if (!container) return
|
||||
if (ctx.file.active()?.path !== local.path) return
|
||||
// if (ctx.file.active()?.path !== local.path) return
|
||||
if (ticking) return
|
||||
ticking = true
|
||||
requestAnimationFrame(() => {
|
||||
@@ -102,7 +102,7 @@ export function Code(props: Props) {
|
||||
const onSelectionChange = () => {
|
||||
if (!container) return
|
||||
if (isProgrammaticSelection) return
|
||||
if (ctx.file.active()?.path !== local.path) return
|
||||
// if (ctx.file.active()?.path !== local.path) return
|
||||
const d = getSelectionInContainer(container)
|
||||
if (!d) return
|
||||
const p = ctx.file.node(local.path)?.selection
|
||||
@@ -112,7 +112,7 @@ export function Code(props: Props) {
|
||||
|
||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (ctx.file.active()?.path !== local.path) return
|
||||
// if (ctx.file.active()?.path !== local.path) return
|
||||
const ae = document.activeElement as HTMLElement | undefined
|
||||
const tag = (ae?.tagName || "").toLowerCase()
|
||||
const inputFocused = !!ae && (tag === "input" || tag === "textarea" || ae.isContentEditable)
|
||||
@@ -155,7 +155,7 @@ export function Code(props: Props) {
|
||||
createEffect(() => {
|
||||
const content = html()
|
||||
if (!container || !content) return
|
||||
if (ctx.file.active()?.path !== local.path) return
|
||||
// if (ctx.file.active()?.path !== local.path) return
|
||||
const codeEl = container.querySelector("code") as HTMLElement | undefined
|
||||
if (!codeEl) return
|
||||
const target = ctx.file.node(local.path)?.selection
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function FileTree(props: {
|
||||
component={p.as ?? "div"}
|
||||
classList={{
|
||||
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
|
||||
"bg-background-element": local.file.active()?.path === p.node.path,
|
||||
// "bg-background-element": local.file.active()?.path === p.node.path,
|
||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||
}}
|
||||
style={`padding-left: ${level * 10}px`}
|
||||
@@ -55,7 +55,7 @@ export default function FileTree(props: {
|
||||
"text-xs whitespace-nowrap truncate": true,
|
||||
"text-text-muted/40": p.node.ignored,
|
||||
"text-text-muted/80": !p.node.ignored,
|
||||
"!text-text": local.file.active()?.path === p.node.path,
|
||||
// "!text-text": local.file.active()?.path === p.node.path,
|
||||
"!text-primary": local.file.changed(p.node.path),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { Part } from "@opencode-ai/ui"
|
||||
import { useSync } from "@/context/sync"
|
||||
import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
|
||||
const sync = useSync()
|
||||
const parts = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id]))
|
||||
const done = createMemo(() => props.done ?? false)
|
||||
const currentTask = createMemo(
|
||||
() =>
|
||||
parts().findLast(
|
||||
(p) =>
|
||||
p &&
|
||||
p.type === "tool" &&
|
||||
p.tool === "task" &&
|
||||
p.state &&
|
||||
"metadata" in p.state &&
|
||||
p.state.metadata &&
|
||||
p.state.metadata.sessionId &&
|
||||
p.state.status === "running",
|
||||
) as ToolPart,
|
||||
)
|
||||
const resolvedParts = createMemo(() => {
|
||||
let resolved = parts()
|
||||
const task = currentTask()
|
||||
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
|
||||
const messages = sync.data.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant")
|
||||
resolved = messages?.flatMap((m) => sync.data.part[m.id]) ?? parts()
|
||||
}
|
||||
return resolved
|
||||
})
|
||||
// const currentText = createMemo(
|
||||
// () =>
|
||||
// resolvedParts().findLast((p) => p?.type === "text")?.text ||
|
||||
// resolvedParts().findLast((p) => p?.type === "reasoning")?.text,
|
||||
// )
|
||||
const eligibleItems = createMemo(() => {
|
||||
return resolvedParts().filter((p) => p?.type === "tool" && p?.state.status === "completed") as ToolPart[]
|
||||
})
|
||||
const finishedItems = createMemo<(JSXElement | ToolPart)[]>(() => [
|
||||
<div class="h-8 w-full" />,
|
||||
<div class="h-8 w-full" />,
|
||||
<div class="h-8 w-full" />,
|
||||
...eligibleItems(),
|
||||
...(done() ? [<div class="h-8 w-full" />, <div class="h-8 w-full" />, <div class="h-8 w-full" />] : []),
|
||||
])
|
||||
|
||||
const delay = createMemo(() => (done() ? 220 : 400))
|
||||
const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
|
||||
|
||||
createEffect(() => {
|
||||
const total = finishedItems().length
|
||||
if (total > visibleCount()) {
|
||||
const timer = setTimeout(() => {
|
||||
setVisibleCount((prev) => prev + 1)
|
||||
}, delay())
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
} else if (total < visibleCount()) {
|
||||
setVisibleCount(total)
|
||||
}
|
||||
})
|
||||
|
||||
const translateY = createMemo(() => {
|
||||
const total = visibleCount()
|
||||
if (total < 2) return "0px"
|
||||
return `-${(total - 2) * 40 - 8}px`
|
||||
})
|
||||
|
||||
const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
|
||||
const rawStatus = createMemo(() => {
|
||||
const defaultStatus = "Working..."
|
||||
const last = lastPart()
|
||||
if (!last) return defaultStatus
|
||||
|
||||
if (last.type === "tool") {
|
||||
switch (last.tool) {
|
||||
case "task":
|
||||
return "Delegating work..."
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Planning next steps..."
|
||||
case "read":
|
||||
return "Gathering context..."
|
||||
case "list":
|
||||
case "grep":
|
||||
case "glob":
|
||||
return "Searching the codebase..."
|
||||
case "webfetch":
|
||||
return "Searching the web..."
|
||||
case "edit":
|
||||
case "write":
|
||||
return "Making edits..."
|
||||
case "bash":
|
||||
return "Running commands..."
|
||||
default:
|
||||
break
|
||||
}
|
||||
} else if (last.type === "reasoning") {
|
||||
return "Thinking..."
|
||||
} else if (last.type === "text") {
|
||||
return "Gathering thoughts..."
|
||||
}
|
||||
return defaultStatus
|
||||
})
|
||||
|
||||
const [status, setStatus] = createSignal(rawStatus())
|
||||
let lastStatusChange = Date.now()
|
||||
let statusTimeout: number | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const newStatus = rawStatus()
|
||||
if (newStatus === status()) return
|
||||
|
||||
const timeSinceLastChange = Date.now() - lastStatusChange
|
||||
|
||||
if (timeSinceLastChange >= 1000) {
|
||||
setStatus(newStatus)
|
||||
lastStatusChange = Date.now()
|
||||
if (statusTimeout) {
|
||||
clearTimeout(statusTimeout)
|
||||
statusTimeout = undefined
|
||||
}
|
||||
} else {
|
||||
if (statusTimeout) clearTimeout(statusTimeout)
|
||||
statusTimeout = setTimeout(() => {
|
||||
setStatus(rawStatus())
|
||||
lastStatusChange = Date.now()
|
||||
statusTimeout = undefined
|
||||
}, 1000 - timeSinceLastChange) as unknown as number
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
{/* <Show when={currentText()}> */}
|
||||
{/* {(text) => ( */}
|
||||
{/* <div */}
|
||||
{/* class="h-20 flex flex-col justify-end overflow-hidden py-3 */}
|
||||
{/* mask-alpha mask-t-from-80% mask-t-from-background-base mask-t-to-transparent" */}
|
||||
{/* > */}
|
||||
{/* <Markdown text={text()} class="w-full shrink-0 overflow-visible" /> */}
|
||||
{/* </div> */}
|
||||
{/* )} */}
|
||||
{/* </Show> */}
|
||||
<div class="flex items-center gap-x-5 pl-3 border border-transparent text-text-base">
|
||||
<Spinner /> <span class="text-12-medium">{status()}</span>
|
||||
</div>
|
||||
<Show when={eligibleItems().length > 0}>
|
||||
<div
|
||||
class="h-30 overflow-hidden pointer-events-none pb-1
|
||||
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
|
||||
mask-b-from-95% mask-b-from-background-base mask-b-to-transparent"
|
||||
>
|
||||
<div
|
||||
class="w-full flex flex-col items-start self-stretch gap-2 py-8
|
||||
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{ transform: `translateY(${translateY()})` }}
|
||||
>
|
||||
<For each={finishedItems()}>
|
||||
{(part) => (
|
||||
<Switch>
|
||||
<Match when={part && typeof part === "object" && "type" in part && part}>
|
||||
{(p) => {
|
||||
const part = p() as ToolPart
|
||||
const message = createMemo(() =>
|
||||
sync.data.message[part.sessionID].find((m) => m.id === part.messageID),
|
||||
)
|
||||
return (
|
||||
<div class="h-8 flex items-center w-full">
|
||||
<Part message={message()!} part={part} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="h-8 flex items-center w-full">{part as JSXElement}</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
|
||||
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({
|
||||
items: local.file.search,
|
||||
items: local.file.searchFilesAndDirectories,
|
||||
key: (x) => x,
|
||||
onSelect: (path) => {
|
||||
if (!path) return
|
||||
@@ -307,34 +307,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
||||
<Show when={store.popoverIsOpen}>
|
||||
<div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
|
||||
<For each={flat()}>
|
||||
{(i) => (
|
||||
<div
|
||||
classList={{
|
||||
"w-full flex items-center justify-between rounded-md": true,
|
||||
"bg-surface-raised-base-hover": active() === i,
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-x-2 grow min-w-0">
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}>
|
||||
<For each={flat()}>
|
||||
{(i) => (
|
||||
<div
|
||||
classList={{
|
||||
"w-full flex items-center justify-between rounded-md": true,
|
||||
"bg-surface-raised-base-hover": active() === i,
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-x-2 grow min-w-0">
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
|
||||
"rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
|
||||
"rounded-2xl overflow-clip focus-within:border-transparent focus-within:shadow-xs-border-select": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
@@ -365,6 +367,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize"
|
||||
variant="ghost"
|
||||
/>
|
||||
<SelectDialog
|
||||
title="Select model"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ComponentProps, For } from "solid-js"
|
||||
|
||||
export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
|
||||
const squares = Array.from({ length: 16 }, (_, i) => ({
|
||||
id: i,
|
||||
x: (i % 4) * 4,
|
||||
y: Math.floor(i / 4) * 4,
|
||||
delay: Math.random() * 3,
|
||||
duration: 2 + Math.random() * 2,
|
||||
}))
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
classList={{
|
||||
"size-4": true,
|
||||
...(props.classList ?? {}),
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
fill="currentColor"
|
||||
>
|
||||
<For each={squares}>
|
||||
{(square) => (
|
||||
<rect
|
||||
x={square.x}
|
||||
y={square.y}
|
||||
width="3"
|
||||
height="3"
|
||||
rx="1"
|
||||
style={{
|
||||
animation: `pulse-opacity ${square.duration}s ease-in-out infinite`,
|
||||
"animation-delay": `${square.delay}s`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { pipe, sumBy, uniqueBy } from "remeda"
|
||||
import type {
|
||||
FileContent,
|
||||
FileNode,
|
||||
Model,
|
||||
Provider,
|
||||
File as FileStatus,
|
||||
Part,
|
||||
Message,
|
||||
AssistantMessage,
|
||||
} from "@opencode-ai/sdk"
|
||||
import { uniqueBy } from "remeda"
|
||||
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
@@ -162,10 +153,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
|
||||
|
||||
const cycle = (direction: 1 | -1) => {
|
||||
const recentList = recent()
|
||||
const current = currentModel()
|
||||
if (!current) return
|
||||
|
||||
const index = recentList.findIndex((x) => x?.provider.id === current.provider.id && x?.id === current.id)
|
||||
if (index === -1) return
|
||||
|
||||
let next = index + direction
|
||||
if (next < 0) next = recentList.length - 1
|
||||
if (next >= recentList.length) next = 0
|
||||
|
||||
const val = recentList[next]
|
||||
if (!val) return
|
||||
|
||||
model.set({
|
||||
providerID: val.provider.id,
|
||||
modelID: val.id,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
current: currentModel,
|
||||
recent,
|
||||
list,
|
||||
cycle,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("model", agent.current().name, model ?? fallbackModel())
|
||||
@@ -182,18 +195,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const file = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
node: Record<string, LocalFile>
|
||||
opened: string[]
|
||||
active?: string
|
||||
// opened: string[]
|
||||
// active?: string
|
||||
}>({
|
||||
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||
opened: [],
|
||||
// opened: [],
|
||||
})
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (!store.active) return undefined
|
||||
return store.node[store.active]
|
||||
})
|
||||
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
||||
// const active = createMemo(() => {
|
||||
// if (!store.active) return undefined
|
||||
// return store.node[store.active]
|
||||
// })
|
||||
// const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
||||
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||
|
||||
@@ -281,16 +294,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
|
||||
const relativePath = relative(path)
|
||||
if (!store.node[relativePath]) await fetch(path)
|
||||
setStore("opened", (x) => {
|
||||
if (x.includes(relativePath)) return x
|
||||
return [
|
||||
...opened()
|
||||
.filter((x) => x.pinned)
|
||||
.map((x) => x.path),
|
||||
relativePath,
|
||||
]
|
||||
})
|
||||
setStore("active", relativePath)
|
||||
// setStore("opened", (x) => {
|
||||
// if (x.includes(relativePath)) return x
|
||||
// return [
|
||||
// ...opened()
|
||||
// .filter((x) => x.pinned)
|
||||
// .map((x) => x.path),
|
||||
// relativePath,
|
||||
// ]
|
||||
// })
|
||||
// setStore("active", relativePath)
|
||||
context.addActive()
|
||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||
@@ -312,7 +325,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
}
|
||||
|
||||
const search = (query: string) => sdk.client.find.files({ query: { query } }).then((x) => x.data!)
|
||||
const searchFiles = (query: string) =>
|
||||
sdk.client.find.files({ query: { query, dirs: "false" } }).then((x) => x.data!)
|
||||
const searchFilesAndDirectories = (query: string) =>
|
||||
sdk.client.find.files({ query: { query, dirs: "true" } }).then((x) => x.data!)
|
||||
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
@@ -341,22 +357,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
return {
|
||||
active,
|
||||
opened,
|
||||
node: (path: string) => store.node[path],
|
||||
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
|
||||
open,
|
||||
load,
|
||||
init,
|
||||
close(path: string) {
|
||||
setStore("opened", (opened) => opened.filter((x) => x !== path))
|
||||
if (store.active === path) {
|
||||
const index = store.opened.findIndex((f) => f === path)
|
||||
const previous = store.opened[Math.max(0, index - 1)]
|
||||
setStore("active", previous)
|
||||
}
|
||||
resetNode(path)
|
||||
},
|
||||
expand(path: string) {
|
||||
setStore("node", path, "expanded", true)
|
||||
if (store.node[path].loaded) return
|
||||
@@ -372,17 +377,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
scroll(path: string, scrollTop: number) {
|
||||
setStore("node", path, "scrollTop", scrollTop)
|
||||
},
|
||||
move(path: string, to: number) {
|
||||
const index = store.opened.findIndex((f) => f === path)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"opened",
|
||||
produce((opened) => {
|
||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
setStore("node", path, "pinned", true)
|
||||
},
|
||||
view(path: string): View {
|
||||
const n = store.node[path]
|
||||
return n && n.view ? n.view : "raw"
|
||||
@@ -420,16 +414,51 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
!x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
|
||||
)
|
||||
},
|
||||
search,
|
||||
searchFiles,
|
||||
searchFilesAndDirectories,
|
||||
relative,
|
||||
// active,
|
||||
// opened,
|
||||
// close(path: string) {
|
||||
// setStore("opened", (opened) => opened.filter((x) => x !== path))
|
||||
// if (store.active === path) {
|
||||
// const index = store.opened.findIndex((f) => f === path)
|
||||
// const previous = store.opened[Math.max(0, index - 1)]
|
||||
// setStore("active", previous)
|
||||
// }
|
||||
// resetNode(path)
|
||||
// },
|
||||
// move(path: string, to: number) {
|
||||
// const index = store.opened.findIndex((f) => f === path)
|
||||
// if (index === -1) return
|
||||
// setStore(
|
||||
// "opened",
|
||||
// produce((opened) => {
|
||||
// opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
// }),
|
||||
// )
|
||||
// setStore("node", path, "pinned", true)
|
||||
// },
|
||||
}
|
||||
})()
|
||||
|
||||
const session = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
active?: string
|
||||
activeMessage?: string
|
||||
}>({})
|
||||
tabs: Record<
|
||||
string,
|
||||
{
|
||||
active?: string
|
||||
opened: string[]
|
||||
}
|
||||
>
|
||||
}>({
|
||||
tabs: {
|
||||
"": {
|
||||
opened: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (!store.active) return undefined
|
||||
@@ -439,134 +468,69 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
createEffect(() => {
|
||||
if (!store.active) return
|
||||
sync.session.sync(store.active)
|
||||
})
|
||||
|
||||
const valid = (part: Part) => {
|
||||
if (!part) return false
|
||||
switch (part.type) {
|
||||
case "step-start":
|
||||
case "step-finish":
|
||||
case "file":
|
||||
case "patch":
|
||||
return false
|
||||
case "text":
|
||||
return !part.synthetic && part.text.trim()
|
||||
case "reasoning":
|
||||
return part.text.trim()
|
||||
case "tool":
|
||||
switch (part.tool) {
|
||||
case "todoread":
|
||||
case "todowrite":
|
||||
case "list":
|
||||
case "grep":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
if (!store.tabs[store.active]) {
|
||||
setStore("tabs", store.active, {
|
||||
opened: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasValidParts = (message: Message) => {
|
||||
return sync.data.part[message.id]?.filter(valid).length > 0
|
||||
}
|
||||
// const hasTextPart = (message: Message) => {
|
||||
// return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
|
||||
// }
|
||||
|
||||
const messages = createMemo(() => (store.active ? (sync.data.message[store.active] ?? []) : []))
|
||||
const messagesWithValidParts = createMemo(() => messages().filter(hasValidParts) ?? [])
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => b.id.localeCompare(a.id)),
|
||||
)
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
messages(),
|
||||
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
||||
)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const last = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant") as AssistantMessage
|
||||
})
|
||||
|
||||
const lastUserMessage = createMemo(() => {
|
||||
return userMessages()?.at(0)
|
||||
})
|
||||
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!store.active || !store.activeMessage) return lastUserMessage()
|
||||
return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage)
|
||||
})
|
||||
|
||||
const model = createMemo(() => {
|
||||
if (!last()) return
|
||||
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
|
||||
return model
|
||||
})
|
||||
|
||||
const tokens = createMemo(() => {
|
||||
if (!last()) return
|
||||
const tokens = last().tokens
|
||||
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
if (!last()) return
|
||||
if (!model()?.limit.context) return 0
|
||||
const tokens = last().tokens
|
||||
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
return Math.round((total / model()!.limit.context) * 100)
|
||||
})
|
||||
|
||||
const getMessageText = (message: Message | Message[] | undefined): string => {
|
||||
if (!message) return ""
|
||||
if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ")
|
||||
return sync.data.part[message.id]
|
||||
?.filter((p) => p.type === "text")
|
||||
?.filter((p) => !p.synthetic)
|
||||
.map((p) => p.text)
|
||||
.join(" ")
|
||||
}
|
||||
const tabs = createMemo(() => store.tabs[store.active ?? ""])
|
||||
|
||||
return {
|
||||
active,
|
||||
activeMessage,
|
||||
lastUserMessage,
|
||||
cost,
|
||||
last,
|
||||
model,
|
||||
tokens,
|
||||
context,
|
||||
messages,
|
||||
messagesWithValidParts,
|
||||
userMessages,
|
||||
// working,
|
||||
getMessageText,
|
||||
setActive(sessionId: string | undefined) {
|
||||
setStore("active", sessionId)
|
||||
setStore("activeMessage", undefined)
|
||||
},
|
||||
clearActive() {
|
||||
setStore("active", undefined)
|
||||
setStore("activeMessage", undefined)
|
||||
},
|
||||
setActiveMessage(messageId: string | undefined) {
|
||||
setStore("activeMessage", messageId)
|
||||
tabs,
|
||||
copyTabs(from: string, to: string) {
|
||||
setStore("tabs", to, {
|
||||
opened: store.tabs[from]?.opened ?? [],
|
||||
})
|
||||
},
|
||||
clearActiveMessage() {
|
||||
setStore("activeMessage", undefined)
|
||||
setActiveTab(tab: string | undefined) {
|
||||
setStore("tabs", store.active ?? "", "active", tab)
|
||||
},
|
||||
async open(tab: string) {
|
||||
if (tab !== "chat") {
|
||||
await file.open(tab)
|
||||
}
|
||||
if (!tabs()?.opened?.includes(tab)) {
|
||||
setStore("tabs", store.active ?? "", "opened", [...(tabs()?.opened ?? []), tab])
|
||||
}
|
||||
setStore("tabs", store.active ?? "", "active", tab)
|
||||
},
|
||||
close(tab: string) {
|
||||
batch(() => {
|
||||
if (!tabs()) return
|
||||
setStore("tabs", store.active ?? "", {
|
||||
active: tabs()!.active,
|
||||
opened: tabs()!.opened.filter((x) => x !== tab),
|
||||
})
|
||||
if (tabs()!.active === tab) {
|
||||
const index = tabs()!.opened.findIndex((f) => f === tab)
|
||||
const previous = tabs()!.opened[Math.max(0, index - 1)]
|
||||
setStore("tabs", store.active ?? "", "active", previous)
|
||||
}
|
||||
})
|
||||
},
|
||||
move(tab: string, to: number) {
|
||||
if (!tabs()) return
|
||||
const index = tabs()!.opened.findIndex((f) => f === tab)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"tabs",
|
||||
store.active ?? "",
|
||||
"opened",
|
||||
produce((opened) => {
|
||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
// setStore("node", path, "pinned", true)
|
||||
},
|
||||
}
|
||||
})()
|
||||
@@ -589,9 +553,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
all() {
|
||||
return store.items
|
||||
},
|
||||
active() {
|
||||
return store.activeTab ? file.active() : undefined
|
||||
},
|
||||
// active() {
|
||||
// return store.activeTab ? file.active() : undefined
|
||||
// },
|
||||
addActive() {
|
||||
setStore("activeTab", true)
|
||||
},
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk"
|
||||
import type {
|
||||
Message,
|
||||
Agent,
|
||||
Provider,
|
||||
Session,
|
||||
Part,
|
||||
Config,
|
||||
Path,
|
||||
File,
|
||||
FileNode,
|
||||
Project,
|
||||
Command,
|
||||
} from "@opencode-ai/sdk"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createMemo } from "solid-js"
|
||||
import { Binary } from "@/utils/binary"
|
||||
@@ -124,7 +136,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||
const sanitizePart = (part: Part) => {
|
||||
if (part.type === "tool") {
|
||||
if (part.state.status === "completed") {
|
||||
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)
|
||||
@@ -135,6 +147,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
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
|
||||
|
||||
@@ -9,13 +9,15 @@ import {
|
||||
Accordion,
|
||||
Diff,
|
||||
Collapsible,
|
||||
Part,
|
||||
DiffChanges,
|
||||
ProgressCircle,
|
||||
Message,
|
||||
Typewriter,
|
||||
Card,
|
||||
Code,
|
||||
} from "@opencode-ai/ui"
|
||||
import { FileIcon } from "@/ui"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { MessageProgress } from "@/components/message-progress"
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
|
||||
import { useLocal, type LocalFile } from "@/context/local"
|
||||
import { createStore } from "solid-js/store"
|
||||
@@ -33,11 +35,11 @@ import {
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import type { JSX } from "solid-js"
|
||||
import { Code } from "@/components/code"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
||||
import { Markdown } from "@opencode-ai/ui"
|
||||
import { Spinner } from "@/components/spinner"
|
||||
|
||||
export default function Page() {
|
||||
const local = useLocal()
|
||||
@@ -51,13 +53,6 @@ export default function Page() {
|
||||
let messageScrollElement!: HTMLDivElement
|
||||
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
|
||||
|
||||
createEffect(() => {
|
||||
if (!local.session.activeMessage()) return
|
||||
if (!messageScrollElement) return
|
||||
const element = messageScrollElement.querySelector(`[data-message="${local.session.activeMessage()?.id}"]`)
|
||||
element?.scrollIntoView({ block: "start", behavior: "instant" })
|
||||
})
|
||||
|
||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||
|
||||
onMount(() => {
|
||||
@@ -87,26 +82,26 @@ export default function Page() {
|
||||
return
|
||||
}
|
||||
|
||||
if (local.file.active()) {
|
||||
const active = local.file.active()!
|
||||
if (event.key === "Enter" && active.selection) {
|
||||
local.context.add({
|
||||
type: "file",
|
||||
path: active.path,
|
||||
selection: { ...active.selection },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.getModifierState(MOD)) {
|
||||
if (event.key.toLowerCase() === "a") {
|
||||
return
|
||||
}
|
||||
if (event.key.toLowerCase() === "c") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (local.file.active()) {
|
||||
// const active = local.file.active()!
|
||||
// if (event.key === "Enter" && active.selection) {
|
||||
// local.context.add({
|
||||
// type: "file",
|
||||
// path: active.path,
|
||||
// selection: { ...active.selection },
|
||||
// })
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// if (event.getModifierState(MOD)) {
|
||||
// if (event.key.toLowerCase() === "a") {
|
||||
// return
|
||||
// }
|
||||
// if (event.key.toLowerCase() === "c") {
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||
inputRef?.focus()
|
||||
@@ -136,21 +131,22 @@ export default function Page() {
|
||||
}
|
||||
}
|
||||
|
||||
const navigateChange = (dir: 1 | -1) => {
|
||||
const active = local.file.active()
|
||||
if (!active) return
|
||||
const current = local.file.changeIndex(active.path)
|
||||
const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
|
||||
local.file.setChangeIndex(active.path, next)
|
||||
}
|
||||
// const navigateChange = (dir: 1 | -1) => {
|
||||
// const active = local.file.active()
|
||||
// if (!active) return
|
||||
// const current = local.file.changeIndex(active.path)
|
||||
// const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
|
||||
// local.file.setChangeIndex(active.path, next)
|
||||
// }
|
||||
|
||||
const handleTabChange = (path: string) => {
|
||||
if (path === "chat" || path === "review") return
|
||||
local.file.open(path)
|
||||
local.session.setActiveTab(path)
|
||||
if (path === "chat") return
|
||||
local.session.open(path)
|
||||
}
|
||||
|
||||
const handleTabClose = (file: LocalFile) => {
|
||||
local.file.close(file.path)
|
||||
local.session.close(file.path)
|
||||
}
|
||||
|
||||
const handleDragStart = (event: unknown) => {
|
||||
@@ -162,11 +158,11 @@ export default function Page() {
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const currentFiles = local.file.opened().map((file) => file.path)
|
||||
const fromIndex = currentFiles.indexOf(draggable.id.toString())
|
||||
const toIndex = currentFiles.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex) {
|
||||
local.file.move(draggable.id.toString(), toIndex)
|
||||
const currentFiles = local.session.tabs()?.opened.map((file) => file)
|
||||
const fromIndex = currentFiles?.indexOf(draggable.id.toString())
|
||||
const toIndex = currentFiles?.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex && toIndex !== undefined) {
|
||||
local.session.move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,20 +171,20 @@ export default function Page() {
|
||||
setActiveItem(undefined)
|
||||
}
|
||||
|
||||
const scrollDiffItem = (element: HTMLElement) => {
|
||||
element.scrollIntoView({ block: "start", behavior: "instant" })
|
||||
}
|
||||
// const scrollDiffItem = (element: HTMLElement) => {
|
||||
// element.scrollIntoView({ block: "start", behavior: "instant" })
|
||||
// }
|
||||
|
||||
const handleDiffTriggerClick = (event: MouseEvent) => {
|
||||
// disabling scroll to diff for now
|
||||
return
|
||||
const target = event.currentTarget as HTMLElement
|
||||
queueMicrotask(() => {
|
||||
if (target.getAttribute("aria-expanded") !== "true") return
|
||||
const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
|
||||
if (!item) return
|
||||
scrollDiffItem(item)
|
||||
})
|
||||
// const target = event.currentTarget as HTMLElement
|
||||
// queueMicrotask(() => {
|
||||
// if (target.getAttribute("aria-expanded") !== "true") return
|
||||
// const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
|
||||
// if (!item) return
|
||||
// scrollDiffItem(item)
|
||||
// })
|
||||
}
|
||||
|
||||
const handlePromptSubmit = async (parts: ContentPart[]) => {
|
||||
@@ -201,6 +197,10 @@ export default function Page() {
|
||||
if (!session) return
|
||||
|
||||
local.session.setActive(session.id)
|
||||
if (!existingSession) {
|
||||
local.session.copyTabs("", session.id)
|
||||
}
|
||||
local.session.setActiveTab(undefined)
|
||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||
|
||||
const text = parts.map((part) => part.content).join("")
|
||||
@@ -423,20 +423,26 @@ export default function Page() {
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs onChange={handleTabChange}>
|
||||
<Tabs value={local.session.tabs()?.active ?? "chat"} onChange={handleTabChange}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
|
||||
<div>Chat</div>
|
||||
<Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5">
|
||||
<ProgressCircle percentage={local.session.context() ?? 0} />
|
||||
<div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div>
|
||||
</Tooltip>
|
||||
{/* <Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5"> */}
|
||||
{/* <ProgressCircle percentage={local.session.context() ?? 0} /> */}
|
||||
{/* <div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div> */}
|
||||
{/* </Tooltip> */}
|
||||
</Tabs.Trigger>
|
||||
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
|
||||
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
|
||||
<SortableProvider ids={local.session.tabs()?.opened ?? []}>
|
||||
<For each={local.session.tabs()?.opened ?? []}>
|
||||
{(file) => (
|
||||
<SortableTab
|
||||
file={local.file.node(file)}
|
||||
onTabClick={handleFileClick}
|
||||
onTabClose={handleTabClose}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
||||
@@ -448,64 +454,6 @@ export default function Page() {
|
||||
/>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40">
|
||||
<Show when={local.file.active() && local.file.active()!.content?.diff}>
|
||||
{(() => {
|
||||
const activeFile = local.file.active()!
|
||||
const view = local.file.view(activeFile.path)
|
||||
return (
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={view !== "raw"}>
|
||||
<div class="mr-1 flex items-center gap-1">
|
||||
<Tooltip value="Previous change" placement="bottom">
|
||||
<IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
|
||||
</Tooltip>
|
||||
<Tooltip value="Next change" placement="bottom">
|
||||
<IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Tooltip value="Raw" placement="bottom">
|
||||
<IconButton
|
||||
icon="file-text"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "raw",
|
||||
"text-text-muted/70": view !== "raw",
|
||||
"bg-background-element": view === "raw",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "raw")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip value="Unified diff" placement="bottom">
|
||||
<IconButton
|
||||
icon="checklist"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-unified",
|
||||
"text-text-muted/70": view !== "diff-unified",
|
||||
"bg-background-element": view === "diff-unified",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip value="Split diff" placement="bottom">
|
||||
<IconButton
|
||||
icon="columns"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-split",
|
||||
"text-text-muted/70": view !== "diff-split",
|
||||
"bg-background-element": view === "diff-split",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "diff-split")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
|
||||
<div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
|
||||
@@ -533,262 +481,289 @@ export default function Page() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(activeSession) => (
|
||||
<div class="pt-3 flex flex-col flex-1 min-h-0">
|
||||
<div class="flex-1 min-h-0">
|
||||
<Show when={local.session.userMessages().length > 1}>
|
||||
<ul
|
||||
role="list"
|
||||
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
|
||||
>
|
||||
<For each={local.session.userMessages()}>
|
||||
{(message) => {
|
||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||
{(session) => {
|
||||
const [store, setStore] = createStore<{
|
||||
messageId?: string
|
||||
}>()
|
||||
|
||||
return (
|
||||
<li
|
||||
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
|
||||
onClick={() => local.session.setActiveMessage(message.id)}
|
||||
>
|
||||
<DiffChanges diff={diffs()} variant="bars" />
|
||||
<div
|
||||
data-active={local.session.activeMessage()?.id === message.id}
|
||||
classList={{
|
||||
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
||||
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
||||
}}
|
||||
>
|
||||
{message.summary?.title ?? local.session.getMessageText(message)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
|
||||
<div class="flex flex-col items-start gap-50 pb-50">
|
||||
<For each={local.session.userMessages()}>
|
||||
const messages = createMemo(() => sync.data.message[session().id] ?? [])
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => b.id.localeCompare(a.id)),
|
||||
)
|
||||
const lastUserMessage = createMemo(() => {
|
||||
return userMessages()?.at(0)
|
||||
})
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!store.messageId) return lastUserMessage()
|
||||
return userMessages()?.find((m) => m.id === store.messageId)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="pt-3 flex flex-col flex-1 min-h-0">
|
||||
<div class="flex-1 min-h-0">
|
||||
<Show when={userMessages().length > 1}>
|
||||
<ul
|
||||
role="list"
|
||||
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
|
||||
>
|
||||
<For each={userMessages()}>
|
||||
{(message) => {
|
||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||
const assistantMessages = createMemo(() => {
|
||||
return sync.data.message[session().id]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||
) as AssistantMessageType[]
|
||||
})
|
||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||
const working = createMemo(() => !message.summary?.body && !error())
|
||||
|
||||
return (
|
||||
<li class="group/li flex items-center self-stretch">
|
||||
<button
|
||||
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
|
||||
onClick={() => setStore("messageId", message.id)}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges diff={diffs()} variant="bars" />
|
||||
</Match>
|
||||
</Switch>
|
||||
<div
|
||||
data-active={activeMessage()?.id === message.id}
|
||||
classList={{
|
||||
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
||||
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
||||
}}
|
||||
>
|
||||
<Show when={message.summary?.title} fallback="New message">
|
||||
{message.summary?.title}
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
|
||||
<For each={userMessages()}>
|
||||
{(message) => {
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const parts = createMemo(() => sync.data.part[message.id])
|
||||
const prompt = createMemo(() => local.session.getMessageText(message))
|
||||
const title = createMemo(() => message.summary?.title)
|
||||
const summary = createMemo(() => message.summary?.body)
|
||||
const isActive = createMemo(() => activeMessage()?.id === message.id)
|
||||
const [titled, setTitled] = createSignal(!!message.summary?.title)
|
||||
const assistantMessages = createMemo(() => {
|
||||
return sync.data.message[activeSession().id]?.filter(
|
||||
return sync.data.message[session().id]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||
) as AssistantMessageType[]
|
||||
})
|
||||
const working = createMemo(() => !summary())
|
||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||
const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const parts = createMemo(() => sync.data.part[message.id])
|
||||
const title = createMemo(() => message.summary?.title)
|
||||
const summary = createMemo(() => message.summary?.body)
|
||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||
const hasToolPart = createMemo(() =>
|
||||
assistantMessages()
|
||||
?.flatMap((m) => sync.data.part[m.id])
|
||||
.some((p) => p?.type === "tool"),
|
||||
)
|
||||
const working = createMemo(() => !summary() && !error())
|
||||
|
||||
// allowing time for the animations to finish
|
||||
createEffect(() => {
|
||||
title()
|
||||
setTimeout(() => setTitled(!!title()), 10_000)
|
||||
})
|
||||
createEffect(() => {
|
||||
const complete = !!summary() || !!error()
|
||||
setTimeout(() => setCompleted(complete), 1200)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-message={message.id}
|
||||
class="flex flex-col items-start self-stretch gap-8 min-h-screen"
|
||||
>
|
||||
{/* Title */}
|
||||
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
|
||||
<h1 class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
|
||||
{title() ?? prompt()}
|
||||
</h1>
|
||||
</div>
|
||||
<Show when={title}>
|
||||
<Show when={isActive()}>
|
||||
<div
|
||||
data-message={message.id}
|
||||
class="flex flex-col items-start self-stretch gap-8 pb-50"
|
||||
>
|
||||
{/* Title */}
|
||||
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
|
||||
<div class="w-full text-14-medium text-text-strong">
|
||||
<Show
|
||||
when={titled()}
|
||||
fallback={
|
||||
<Typewriter
|
||||
as="h1"
|
||||
text={title()}
|
||||
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
|
||||
{title()}
|
||||
</h1>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="-mt-8">
|
||||
<Message message={message} parts={parts()} />
|
||||
</div>
|
||||
</Show>
|
||||
{/* Summary */}
|
||||
<Show when={!working()}>
|
||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<h2 class="text-12-medium text-text-weak">Summary</h2>
|
||||
<Show when={summary()}>
|
||||
<Markdown text={summary()!} />
|
||||
</Show>
|
||||
</div>
|
||||
<Accordion class="w-full" multiple>
|
||||
<For each={message.summary?.diffs || []}>
|
||||
{(diff) => (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger onClick={handleDiffTriggerClick}>
|
||||
<div class="flex items-center justify-between w-full gap-5">
|
||||
<div class="grow flex items-center gap-5 min-w-0">
|
||||
<FileIcon
|
||||
node={{ path: diff.file, type: "file" }}
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<div class="flex grow min-w-0">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span class="text-text-base truncate-start">
|
||||
{getDirectory(diff.file)}‎
|
||||
{/* Summary */}
|
||||
<Show when={completed()}>
|
||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<h2 class="text-12-medium text-text-weak">
|
||||
<Switch>
|
||||
<Match when={diffs().length}>Summary</Match>
|
||||
<Match when={true}>Response</Match>
|
||||
</Switch>
|
||||
</h2>
|
||||
<Show when={summary()}>
|
||||
{(summary) => (
|
||||
<Markdown
|
||||
classList={{ "[&>*]:fade-up-text": !diffs().length }}
|
||||
text={summary()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Accordion class="w-full" multiple>
|
||||
<For each={diffs()}>
|
||||
{(diff) => (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger onClick={handleDiffTriggerClick}>
|
||||
<div class="flex items-center justify-between w-full gap-5">
|
||||
<div class="grow flex items-center gap-5 min-w-0">
|
||||
<FileIcon
|
||||
node={{ path: diff.file, type: "file" }}
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<div class="flex grow min-w-0">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span class="text-text-base truncate-start">
|
||||
{getDirectory(diff.file)}‎
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-text-strong shrink-0">
|
||||
{getFilename(diff.file)}
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-text-strong shrink-0">
|
||||
{getFilename(diff.file)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 flex gap-4 items-center justify-end">
|
||||
<DiffChanges diff={diff} />
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 flex gap-4 items-center justify-end">
|
||||
<DiffChanges diff={diff} />
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<Diff
|
||||
before={{
|
||||
name: diff.file!,
|
||||
contents: diff.before!,
|
||||
}}
|
||||
after={{
|
||||
name: diff.file!,
|
||||
contents: diff.after!,
|
||||
}}
|
||||
/>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
{/* Response */}
|
||||
<div class="w-full">
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
{(_) => {
|
||||
const items = createMemo(() =>
|
||||
assistantMessages().flatMap((m) => sync.data.part[m.id]),
|
||||
)
|
||||
const finishedItems = createMemo(() =>
|
||||
items().filter(
|
||||
(p) =>
|
||||
(p?.type === "text" && p.time?.end) ||
|
||||
(p?.type === "reasoning" && p.time?.end) ||
|
||||
(p?.type === "tool" && p.state.status === "completed"),
|
||||
),
|
||||
)
|
||||
|
||||
const MINIMUM_DELAY = 800
|
||||
const [visibleCount, setVisibleCount] = createSignal(1)
|
||||
|
||||
createEffect(() => {
|
||||
const total = finishedItems().length
|
||||
if (total > visibleCount()) {
|
||||
const timer = setTimeout(() => {
|
||||
setVisibleCount((prev) => prev + 1)
|
||||
}, MINIMUM_DELAY)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
} else if (total < visibleCount()) {
|
||||
setVisibleCount(total)
|
||||
}
|
||||
})
|
||||
|
||||
const translateY = createMemo(() => {
|
||||
const total = visibleCount()
|
||||
if (total < 2) return "0px"
|
||||
return `-${(total - 2) * 48 - 8}px`
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
class="h-36 overflow-hidden pointer-events-none
|
||||
mask-alpha mask-y-from-66% mask-y-from-background-base mask-y-to-transparent"
|
||||
>
|
||||
<div
|
||||
class="w-full flex flex-col items-start self-stretch gap-2 py-10
|
||||
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{ transform: `translateY(${translateY()})` }}
|
||||
>
|
||||
<For each={finishedItems()}>
|
||||
{(part) => {
|
||||
const message = createMemo(() =>
|
||||
sync.data.message[part.sessionID].find(
|
||||
(m) => m.id === part.messageID,
|
||||
),
|
||||
)
|
||||
return (
|
||||
<div class="h-10 flex items-center w-full">
|
||||
<Switch>
|
||||
<Match when={part.type === "text" && part}>
|
||||
{(p) => (
|
||||
<div
|
||||
textContent={p().text}
|
||||
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning" && part}>
|
||||
{(p) => <Part message={message()!} part={p()} />}
|
||||
</Match>
|
||||
<Match when={part.type === "tool" && part}>
|
||||
{(p) => <Part message={message()!} part={p()} />}
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<Diff
|
||||
before={{
|
||||
name: diff.file!,
|
||||
contents: diff.before!,
|
||||
}}
|
||||
</For>
|
||||
after={{
|
||||
name: diff.file!,
|
||||
contents: diff.after!,
|
||||
}}
|
||||
/>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error() && !expanded()}>
|
||||
<Card variant="error" class="text-text-on-critical-base">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
{/* Response */}
|
||||
<div class="w-full">
|
||||
<Switch>
|
||||
<Match when={!completed()}>
|
||||
<MessageProgress
|
||||
assistantMessages={assistantMessages}
|
||||
done={!working()}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={completed() && hasToolPart()}>
|
||||
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
|
||||
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
||||
<div class="flex items-center gap-1 self-stretch">
|
||||
<div class="text-12-medium">
|
||||
<Switch>
|
||||
<Match when={expanded()}>Hide details</Match>
|
||||
<Match when={!expanded()}>Show details</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Match>
|
||||
<Match when={!working()}>
|
||||
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
|
||||
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
||||
<div class="flex items-center gap-1 self-stretch">
|
||||
<div class="text-12-medium">
|
||||
<Switch>
|
||||
<Match when={expanded()}>Hide steps</Match>
|
||||
<Match when={!expanded()}>Show steps</Match>
|
||||
</Switch>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div class="w-full flex flex-col items-start self-stretch gap-3">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(
|
||||
() => sync.data.part[assistantMessage.id],
|
||||
)
|
||||
return <Message message={assistantMessage} parts={parts()} />
|
||||
}}
|
||||
</For>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="text-text-on-critical-base">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div class="w-full flex flex-col items-start self-stretch gap-3">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(
|
||||
() => sync.data.part[assistantMessage.id],
|
||||
)
|
||||
return <Message message={assistantMessage} parts={parts()} />
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
|
||||
<For each={local.file.opened()}>
|
||||
<For each={local.session.tabs()?.opened}>
|
||||
{(file) => (
|
||||
<Tabs.Content value={file.path} class="select-text">
|
||||
<Tabs.Content value={file} class="select-text">
|
||||
{(() => {
|
||||
const view = local.file.view(file.path)
|
||||
const showRaw = view === "raw" || !file.content?.diff
|
||||
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
|
||||
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
|
||||
{
|
||||
/* const view = local.file.view(file) */
|
||||
}
|
||||
{
|
||||
/* const showRaw = view === "raw" || !file.content?.diff */
|
||||
}
|
||||
{
|
||||
/* const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") */
|
||||
}
|
||||
const node = local.file.node(file)
|
||||
return (
|
||||
<Code
|
||||
file={{ name: node.path, contents: node.content?.content ?? "" }}
|
||||
disableFileHeader
|
||||
overflow="scroll"
|
||||
class="pt-3 pb-40"
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</Tabs.Content>
|
||||
)}
|
||||
@@ -852,10 +827,10 @@ export default function Page() {
|
||||
title="Select file"
|
||||
placeholder="Search files"
|
||||
emptyMessage="No files found"
|
||||
items={local.file.search}
|
||||
items={local.file.searchFiles}
|
||||
key={(x) => x}
|
||||
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
||||
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
|
||||
onSelect={(x) => (x ? local.session.open(x) : undefined)}
|
||||
>
|
||||
{(i) => (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "0.15.30",
|
||||
"version": "1.0.19",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -238,10 +238,16 @@ export default new Hono<{ Bindings: Env }>()
|
||||
|
||||
// Lookup installation
|
||||
const octokit = new Octokit({ auth: appAuth.token })
|
||||
const { data: installation } = await octokit.apps.getRepoInstallation({ owner, repo })
|
||||
const { data: installation } = await octokit.apps.getRepoInstallation({
|
||||
owner,
|
||||
repo,
|
||||
})
|
||||
|
||||
// Get installation token
|
||||
const installationAuth = await auth({ type: "installation", installationId: installation.id })
|
||||
const installationAuth = await auth({
|
||||
type: "installation",
|
||||
installationId: installation.id,
|
||||
})
|
||||
|
||||
return c.json({ token: installationAuth.token })
|
||||
})
|
||||
@@ -274,10 +280,16 @@ export default new Hono<{ Bindings: Env }>()
|
||||
|
||||
// Lookup installation
|
||||
const appClient = new Octokit({ auth: appAuth.token })
|
||||
const { data: installation } = await appClient.apps.getRepoInstallation({ owner, repo })
|
||||
const { data: installation } = await appClient.apps.getRepoInstallation({
|
||||
owner,
|
||||
repo,
|
||||
})
|
||||
|
||||
// Get installation token
|
||||
const installationAuth = await auth({ type: "installation", installationId: installation.id })
|
||||
const installationAuth = await auth({
|
||||
type: "installation",
|
||||
installationId: installation.id,
|
||||
})
|
||||
|
||||
return c.json({ token: installationAuth.token })
|
||||
} catch (e: any) {
|
||||
|
||||
Vendored
+9
-1
@@ -6,6 +6,10 @@
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"ADMIN_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AUTH_API_URL": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
@@ -74,7 +78,11 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_MODELS": {
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
[test]
|
||||
preload = ["./test/preload.ts"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.15.30",
|
||||
"version": "1.0.19",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
@@ -8,7 +8,8 @@
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test",
|
||||
"build": "./script/build.ts",
|
||||
"dev": "bun run ./src/index.ts"
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
"random": "echo 'Random script updated at $(date)'"
|
||||
},
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode"
|
||||
@@ -19,6 +20,7 @@
|
||||
"devDependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "2.2.10",
|
||||
"@ai-sdk/google-vertex": "3.0.16",
|
||||
"@babel/core": "7.28.4",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
@@ -27,12 +29,15 @@
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
"@opencode-ai/script": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -49,29 +54,34 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opentui/core": "0.1.33",
|
||||
"@opentui/solid": "0.1.33",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@pierre/precision-diffs": "catalog:",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "1.0.7",
|
||||
"hono-openapi": "1.1.1",
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
"tree-sitter": "0.22.4",
|
||||
"tree-sitter-bash": "0.23.3",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "3.0.1",
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.22.6",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
export default {
|
||||
// NOTE: FOR markdown, javascript and typescript, we use the opentui built-in parsers
|
||||
// Warn: when taking queries from the nvim-treesitter repo, make sure to include the query dependencies as well
|
||||
// marked with for example `; inherits: ecma` at the top of the file. Just put the dependencies before the actual query.
|
||||
// ALSO: Some queries use breaking changes in the nvim-treesitter repo, that are not compatible with the (web-)tree-sitter parser.
|
||||
parsers: [
|
||||
{
|
||||
filetype: "python",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
|
||||
// it is using "except" nodes that the parser is complaining about, but it has been in the query for 3+ years.
|
||||
// Unclear.
|
||||
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/highlights.scm",
|
||||
"https://github.com/tree-sitter/tree-sitter-python/raw/refs/heads/master/queries/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "rust",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-rust/releases/download/v0.24.0/tree-sitter-rust.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "go",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-go/releases/download/v0.25.0/tree-sitter-go.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "cpp",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-cpp/releases/download/v0.23.4/tree-sitter-cpp.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "csharp",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-c-sharp/releases/download/v0.23.1/tree-sitter-c_sharp.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "bash",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.25.0/tree-sitter-bash.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/bash/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "c",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-c/releases/download/v0.24.1/tree-sitter-c.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "java",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-java/releases/download/v0.23.5/tree-sitter-java.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "ruby",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "php",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-php/releases/download/v0.24.2/tree-sitter-php.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
|
||||
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/php/highlights.scm",
|
||||
"https://github.com/tree-sitter/tree-sitter-php/raw/refs/heads/master/queries/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "scala",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-scala/releases/download/v0.24.0/tree-sitter-scala.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/scala/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "html",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-html/releases/download/v0.23.2/tree-sitter-html.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
|
||||
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/html/highlights.scm",
|
||||
"https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/highlights.scm",
|
||||
],
|
||||
// TODO: Injections not working for some reason
|
||||
// injections: [
|
||||
// "https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/injections.scm",
|
||||
// ],
|
||||
},
|
||||
// injectionMapping: {
|
||||
// nodeTypes: {
|
||||
// script_element: "javascript",
|
||||
// style_element: "css",
|
||||
// },
|
||||
// infoStringMap: {
|
||||
// javascript: "javascript",
|
||||
// css: "css",
|
||||
// },
|
||||
// },
|
||||
},
|
||||
{
|
||||
filetype: "json",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/json/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "haskell",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/haskell/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "css",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-css/releases/download/v0.25.0/tree-sitter-css.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/css/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "julia",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-julia/releases/download/v0.23.1/tree-sitter-julia.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/julia/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "ocaml",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ocaml/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
@@ -7,18 +11,13 @@ const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
import { $ } from "bun"
|
||||
|
||||
import pkg from "../package.json"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
|
||||
const GOARCH: Record<string, string> = {
|
||||
arm64: "arm64",
|
||||
x64: "amd64",
|
||||
"x64-baseline": "amd64",
|
||||
}
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
|
||||
const targets = [
|
||||
const allTargets = [
|
||||
["windows", "x64"],
|
||||
["linux", "arm64"],
|
||||
["linux", "x64"],
|
||||
@@ -28,6 +27,10 @@ const targets = [
|
||||
["darwin", "arm64"],
|
||||
]
|
||||
|
||||
const targets = singleFlag
|
||||
? allTargets.filter(([os, arch]) => os === process.platform && arch === process.arch)
|
||||
: allTargets
|
||||
|
||||
await $`rm -rf dist`
|
||||
|
||||
const binaries: Record<string, string> = {}
|
||||
@@ -35,16 +38,22 @@ for (const [os, arch] of targets) {
|
||||
console.log(`building ${os}-${arch}`)
|
||||
const name = `${pkg.name}-${os}-${arch}`
|
||||
await $`mkdir -p dist/${name}/bin`
|
||||
await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${Script.version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`
|
||||
.cwd("../tui")
|
||||
.quiet()
|
||||
|
||||
const opentui = `@opentui/core-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}`
|
||||
await $`mkdir -p ../../node_modules/${opentui}`
|
||||
await $`npm pack ${opentui}@${pkg.dependencies["@opentui/core"]}`.cwd(path.join(dir, "../../node_modules"))
|
||||
await $`tar -xf ../../node_modules/${opentui.replace("@opentui/", "opentui-")}-*.tgz -C ../../node_modules/${opentui} --strip-components=1`
|
||||
|
||||
const watcher = `@parcel/watcher-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}${os === "linux" ? "-glibc" : ""}`
|
||||
await $`mkdir -p ../../node_modules/${watcher}`
|
||||
await $`npm pack npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet()
|
||||
await $`npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet()
|
||||
await $`tar -xf ../../node_modules/${watcher.replace("@parcel/", "parcel-")}-*.tgz -C ../../node_modules/${watcher} --strip-components=1`
|
||||
|
||||
const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"))
|
||||
await Bun.build({
|
||||
conditions: ["browser"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [solidPlugin],
|
||||
sourcemap: "external",
|
||||
compile: {
|
||||
target: `bun-${os}-${arch}` as any,
|
||||
@@ -52,13 +61,14 @@ for (const [os, arch] of targets) {
|
||||
execArgv: [`--user-agent=opencode/${Script.version}`, `--env-file=""`, `--`],
|
||||
windows: {},
|
||||
},
|
||||
entrypoints: ["./src/index.ts"],
|
||||
entrypoints: ["./src/index.ts", parserWorker, "./src/cli/cmd/tui/worker.ts"],
|
||||
define: {
|
||||
OPENCODE_VERSION: `'${Script.version}'`,
|
||||
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(dir, parserWorker),
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
OPENCODE_TUI_PATH: `'../../../dist/${name}/bin/tui'`,
|
||||
},
|
||||
})
|
||||
|
||||
await $`rm -rf ./dist/${name}/bin/tui`
|
||||
await Bun.file(`dist/${name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
|
||||
@@ -25,8 +25,8 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
[pkg.name]: `./bin/${pkg.name}`,
|
||||
},
|
||||
scripts: {
|
||||
preinstall: "node ./preinstall.mjs",
|
||||
postinstall: "node ./postinstall.mjs",
|
||||
preinstall: "bun ./preinstall.mjs || node ./preinstall.mjs",
|
||||
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
|
||||
},
|
||||
version: Script.version,
|
||||
optionalDependencies: binaries,
|
||||
@@ -125,10 +125,8 @@ if (!Script.preview) {
|
||||
"build() {",
|
||||
` cd "opencode-\${pkgver}"`,
|
||||
` bun install`,
|
||||
" cd packages/tui",
|
||||
` CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=\${pkgver}" -o tui cmd/opencode/main.go`,
|
||||
" cd ../opencode",
|
||||
` bun build --define OPENCODE_TUI_PATH="'$(realpath ../tui/tui)'" --define OPENCODE_VERSION="'\${pkgver}'" --compile --target=bun-linux-x64 --outfile=opencode ./src/index.ts`,
|
||||
" cd ./packages/opencode",
|
||||
` OPENCODE_CHANNEL=latest OPENCODE_VERSION=${pkgver} bun run ./script/build.ts --single`,
|
||||
"}",
|
||||
"",
|
||||
"package() {",
|
||||
|
||||
@@ -28,8 +28,6 @@ import { Storage } from "@/storage/storage"
|
||||
import { Command } from "@/command"
|
||||
import { Agent as Agents } from "@/agent/agent"
|
||||
import { Permission } from "@/permission"
|
||||
import { Session } from "@/session"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import type { Config } from "@/config/config"
|
||||
import { MCP } from "@/mcp"
|
||||
@@ -89,7 +87,11 @@ export namespace ACP {
|
||||
})
|
||||
if (!res) return
|
||||
if (res.outcome.outcome !== "selected") {
|
||||
Permission.respond({ sessionID: permission.sessionID, permissionID: permission.id, response: "reject" })
|
||||
Permission.respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "reject",
|
||||
})
|
||||
return
|
||||
}
|
||||
Permission.respond({
|
||||
@@ -111,9 +113,11 @@ export namespace ACP {
|
||||
const acpSession = this.sessionManager.get(part.sessionID)
|
||||
if (!acpSession) return
|
||||
|
||||
const message = await Storage.read<MessageV2.Info>(["message", part.sessionID, part.messageID]).catch(
|
||||
() => undefined,
|
||||
)
|
||||
const message = await Storage.read<MessageV2.Info>([
|
||||
"message",
|
||||
part.sessionID,
|
||||
part.messageID,
|
||||
]).catch(() => undefined)
|
||||
if (!message || message.role !== "assistant") return
|
||||
|
||||
if (part.type === "tool") {
|
||||
@@ -192,7 +196,9 @@ export namespace ACP {
|
||||
sessionUpdate: "plan",
|
||||
entries: parsedTodos.data.map((todo) => {
|
||||
const status: PlanEntry["status"] =
|
||||
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
|
||||
todo.status === "cancelled"
|
||||
? "completed"
|
||||
: (todo.status as PlanEntry["status"])
|
||||
return {
|
||||
priority: "medium",
|
||||
status,
|
||||
@@ -375,11 +381,6 @@ export namespace ACP {
|
||||
description: command.description ?? "",
|
||||
}))
|
||||
const names = new Set(availableCommands.map((c) => c.name))
|
||||
if (!names.has("init"))
|
||||
availableCommands.push({
|
||||
name: "init",
|
||||
description: "create/update a AGENTS.md",
|
||||
})
|
||||
if (!names.has("compact"))
|
||||
availableCommands.push({
|
||||
name: "compact",
|
||||
@@ -404,7 +405,8 @@ export namespace ACP {
|
||||
description: agent.description,
|
||||
}))
|
||||
|
||||
const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
|
||||
const currentModeId =
|
||||
availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
|
||||
|
||||
const mcpServers: Record<string, Config.Mcp> = {}
|
||||
for (const server of params.mcpServers) {
|
||||
@@ -585,14 +587,6 @@ export namespace ACP {
|
||||
}
|
||||
|
||||
switch (cmd.name) {
|
||||
case "init":
|
||||
await Session.initialize({
|
||||
sessionID,
|
||||
messageID: Identifier.ascending("message"),
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
})
|
||||
break
|
||||
case "compact":
|
||||
await SessionCompaction.run({
|
||||
sessionID,
|
||||
@@ -665,7 +659,9 @@ export namespace ACP {
|
||||
|
||||
function parseUri(
|
||||
uri: string,
|
||||
): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
|
||||
):
|
||||
| { type: "file"; url: string; filename: string; mime: string }
|
||||
| { type: "text"; text: string } {
|
||||
try {
|
||||
if (uri.startsWith("file://")) {
|
||||
const path = uri.slice(7)
|
||||
|
||||
@@ -74,7 +74,10 @@ export namespace BunProc {
|
||||
// - If .npmrc files exist, Bun will use them automatically
|
||||
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
|
||||
// - No need to pass --registry flag
|
||||
log.info("installing package using Bun's default registry resolution", { pkg, version })
|
||||
log.info("installing package using Bun's default registry resolution", {
|
||||
pkg,
|
||||
version,
|
||||
})
|
||||
|
||||
await BunProc.run(args, {
|
||||
cwd: Global.Path.cache,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Global } from "../../global"
|
||||
import { cmd } from "./cmd"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Log } from "../../util/log"
|
||||
|
||||
import { $ } from "bun"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <server>",
|
||||
describe: "attach to a running opencode server",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("server", {
|
||||
type: "string",
|
||||
describe: "http://localhost:4096",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
describe: "session id to continue",
|
||||
type: "string",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
let cmd = [] as string[]
|
||||
const tui = Bun.embeddedFiles.find((item) => (item as File).name.includes("tui")) as File
|
||||
if (tui) {
|
||||
let binaryName = tui.name
|
||||
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
|
||||
binaryName += ".exe"
|
||||
}
|
||||
const binary = path.join(Global.Path.cache, "tui", binaryName)
|
||||
const file = Bun.file(binary)
|
||||
if (!(await file.exists())) {
|
||||
await Bun.write(file, tui, { mode: 0o755 })
|
||||
if (process.platform !== "win32") await fs.chmod(binary, 0o755)
|
||||
}
|
||||
cmd = [binary]
|
||||
}
|
||||
if (!tui) {
|
||||
const dir = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url))
|
||||
let binaryName = `./dist/tui${process.platform === "win32" ? ".exe" : ""}`
|
||||
await $`go build -o ${binaryName} ./main.go`.cwd(dir)
|
||||
cmd = [path.join(dir, binaryName)]
|
||||
}
|
||||
if (args.session) {
|
||||
cmd.push("--session", args.session)
|
||||
}
|
||||
Log.Default.info("tui", {
|
||||
cmd,
|
||||
})
|
||||
const proc = Bun.spawn({
|
||||
cmd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CGO_ENABLED: "0",
|
||||
OPENCODE_SERVER: args.server,
|
||||
},
|
||||
})
|
||||
|
||||
await proc.exited
|
||||
},
|
||||
})
|
||||
@@ -80,7 +80,7 @@ export const AuthLoginCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
if (args.url) {
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Bun.spawn({
|
||||
cmd: wellknown.auth.command,
|
||||
|
||||
@@ -5,6 +5,14 @@ export const GenerateCommand = {
|
||||
command: "generate",
|
||||
handler: async () => {
|
||||
const specs = await Server.openapi()
|
||||
process.stdout.write(JSON.stringify(specs, null, 2))
|
||||
const json = JSON.stringify(specs, null, 2)
|
||||
|
||||
// Wait for stdout to finish writing before process.exit() is called
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(json, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
} satisfies CommandModule
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import path from "path"
|
||||
import { $ } from "bun"
|
||||
import { exec } from "child_process"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { map, pipe, sortBy, values } from "remeda"
|
||||
@@ -20,6 +19,7 @@ import { Provider } from "../../provider/provider"
|
||||
import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { $ } from "bun"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
|
||||
@@ -12,7 +12,6 @@ export const ServeCommand = cmd({
|
||||
default: 0,
|
||||
})
|
||||
.option("hostname", {
|
||||
alias: ["h"],
|
||||
type: "string",
|
||||
describe: "hostname to listen on",
|
||||
default: "127.0.0.1",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { cmd } from "./cmd"
|
||||
import { Session } from "../../session"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { Storage } from "../../storage/storage"
|
||||
import { Project } from "../../project/project"
|
||||
import { Instance } from "../../project/instance"
|
||||
|
||||
interface SessionStats {
|
||||
totalSessions: number
|
||||
@@ -24,10 +30,186 @@ interface SessionStats {
|
||||
|
||||
export const StatsCommand = cmd({
|
||||
command: "stats",
|
||||
handler: async () => {},
|
||||
describe: "show token usage and cost statistics",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.option("days", {
|
||||
describe: "show stats for the last N days (default: all time)",
|
||||
type: "number",
|
||||
})
|
||||
.option("tools", {
|
||||
describe: "number of tools to show (default: all)",
|
||||
type: "number",
|
||||
})
|
||||
.option("project", {
|
||||
describe: "filter by project (default: all projects, empty string: current project)",
|
||||
type: "string",
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const stats = await aggregateSessionStats(args.days, args.project)
|
||||
displayStats(stats, args.tools)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export function displayStats(stats: SessionStats) {
|
||||
async function getCurrentProject(): Promise<Project.Info> {
|
||||
return Instance.project
|
||||
}
|
||||
|
||||
async function getAllSessions(): Promise<Session.Info[]> {
|
||||
const sessions: Session.Info[] = []
|
||||
|
||||
const projectKeys = await Storage.list(["project"])
|
||||
const projects = await Promise.all(projectKeys.map((key) => Storage.read<Project.Info>(key)))
|
||||
|
||||
for (const project of projects) {
|
||||
if (!project) continue
|
||||
|
||||
const sessionKeys = await Storage.list(["session", project.id])
|
||||
const projectSessions = await Promise.all(
|
||||
sessionKeys.map((key) => Storage.read<Session.Info>(key)),
|
||||
)
|
||||
|
||||
for (const session of projectSessions) {
|
||||
if (session) {
|
||||
sessions.push(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
|
||||
const sessions = await getAllSessions()
|
||||
const DAYS_IN_SECOND = 24 * 60 * 60 * 1000
|
||||
const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0
|
||||
|
||||
let filteredSessions = days
|
||||
? sessions.filter((session) => session.time.updated >= cutoffTime)
|
||||
: sessions
|
||||
|
||||
if (projectFilter !== undefined) {
|
||||
if (projectFilter === "") {
|
||||
const currentProject = await getCurrentProject()
|
||||
filteredSessions = filteredSessions.filter(
|
||||
(session) => session.projectID === currentProject.id,
|
||||
)
|
||||
} else {
|
||||
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
|
||||
}
|
||||
}
|
||||
|
||||
const stats: SessionStats = {
|
||||
totalSessions: filteredSessions.length,
|
||||
totalMessages: 0,
|
||||
totalCost: 0,
|
||||
totalTokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
toolUsage: {},
|
||||
dateRange: {
|
||||
earliest: Date.now(),
|
||||
latest: Date.now(),
|
||||
},
|
||||
days: 0,
|
||||
costPerDay: 0,
|
||||
}
|
||||
|
||||
if (filteredSessions.length > 1000) {
|
||||
console.log(
|
||||
`Large dataset detected (${filteredSessions.length} sessions). This may take a while...`,
|
||||
)
|
||||
}
|
||||
|
||||
if (filteredSessions.length === 0) {
|
||||
return stats
|
||||
}
|
||||
|
||||
let earliestTime = Date.now()
|
||||
let latestTime = 0
|
||||
|
||||
const BATCH_SIZE = 20
|
||||
for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
|
||||
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
|
||||
|
||||
const batchPromises = batch.map(async (session) => {
|
||||
const messages = await Session.messages(session.id)
|
||||
|
||||
let sessionCost = 0
|
||||
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
let sessionToolUsage: Record<string, number> = {}
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.info.role === "assistant") {
|
||||
sessionCost += message.info.cost || 0
|
||||
|
||||
if (message.info.tokens) {
|
||||
sessionTokens.input += message.info.tokens.input || 0
|
||||
sessionTokens.output += message.info.tokens.output || 0
|
||||
sessionTokens.reasoning += message.info.tokens.reasoning || 0
|
||||
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
|
||||
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
|
||||
}
|
||||
}
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "tool" && part.tool) {
|
||||
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messageCount: messages.length,
|
||||
sessionCost,
|
||||
sessionTokens,
|
||||
sessionToolUsage,
|
||||
earliestTime: session.time.created,
|
||||
latestTime: session.time.updated,
|
||||
}
|
||||
})
|
||||
|
||||
const batchResults = await Promise.all(batchPromises)
|
||||
|
||||
for (const result of batchResults) {
|
||||
earliestTime = Math.min(earliestTime, result.earliestTime)
|
||||
latestTime = Math.max(latestTime, result.latestTime)
|
||||
|
||||
stats.totalMessages += result.messageCount
|
||||
stats.totalCost += result.sessionCost
|
||||
stats.totalTokens.input += result.sessionTokens.input
|
||||
stats.totalTokens.output += result.sessionTokens.output
|
||||
stats.totalTokens.reasoning += result.sessionTokens.reasoning
|
||||
stats.totalTokens.cache.read += result.sessionTokens.cache.read
|
||||
stats.totalTokens.cache.write += result.sessionTokens.cache.write
|
||||
|
||||
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
|
||||
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND))
|
||||
stats.dateRange = {
|
||||
earliest: earliestTime,
|
||||
latest: latestTime,
|
||||
}
|
||||
stats.days = actualDays
|
||||
stats.costPerDay = stats.totalCost / actualDays
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
export function displayStats(stats: SessionStats, toolLimit?: number) {
|
||||
const width = 56
|
||||
|
||||
function renderRow(label: string, value: string): string {
|
||||
@@ -64,30 +246,35 @@ export function displayStats(stats: SessionStats) {
|
||||
|
||||
// Tool Usage section
|
||||
if (Object.keys(stats.toolUsage).length > 0) {
|
||||
const sortedTools = Object.entries(stats.toolUsage)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 10)
|
||||
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
|
||||
const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools
|
||||
|
||||
console.log("┌────────────────────────────────────────────────────────┐")
|
||||
console.log("│ TOOL USAGE │")
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
|
||||
const maxCount = Math.max(...sortedTools.map(([, count]) => count))
|
||||
const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count))
|
||||
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
|
||||
|
||||
for (const [tool, count] of sortedTools) {
|
||||
for (const [tool, count] of toolsToDisplay) {
|
||||
const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
|
||||
const bar = "█".repeat(barLength)
|
||||
const percentage = ((count / totalToolUsage) * 100).toFixed(1)
|
||||
|
||||
const content = ` ${tool.padEnd(10)} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
|
||||
const padding = Math.max(0, width - content.length)
|
||||
const maxToolLength = 18
|
||||
const truncatedTool =
|
||||
tool.length > maxToolLength ? tool.substring(0, maxToolLength - 2) + ".." : tool
|
||||
const toolName = truncatedTool.padEnd(maxToolLength)
|
||||
|
||||
const content = ` ${toolName} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
|
||||
const padding = Math.max(0, width - content.length - 1)
|
||||
console.log(`│${content}${" ".repeat(padding)} │`)
|
||||
}
|
||||
console.log("└────────────────────────────────────────────────────────┘")
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + "M"
|
||||
|
||||
@@ -65,7 +65,6 @@ export const TuiCommand = cmd({
|
||||
default: 0,
|
||||
})
|
||||
.option("hostname", {
|
||||
alias: ["h"],
|
||||
type: "string",
|
||||
describe: "hostname to listen on",
|
||||
default: "127.0.0.1",
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute, type Route } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal } from "solid-js"
|
||||
import { Installation } from "@/installation"
|
||||
import { Global } from "@/global"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||
import { DialogModel } from "@tui/component/dialog-model"
|
||||
import { DialogStatus } from "@tui/component/dialog-status"
|
||||
import { DialogThemeList } from "@tui/component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { KeybindProvider } from "@tui/context/keybind"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
import { Home } from "@tui/routes/home"
|
||||
import { Session } from "@tui/routes/session"
|
||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import type { SessionRoute } from "./context/route"
|
||||
import { Session as SessionApi } from "@/session"
|
||||
import { TuiEvent } from "./event"
|
||||
import { KVProvider, useKV } from "./context/kv"
|
||||
|
||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
return new Promise((resolve) => {
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(false)
|
||||
process.stdin.removeListener("data", handler)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
const str = data.toString()
|
||||
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
||||
if (match) {
|
||||
cleanup()
|
||||
const color = match[1]
|
||||
// Parse RGB values from color string
|
||||
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0
|
||||
|
||||
if (color.startsWith("rgb:")) {
|
||||
const parts = color.substring(4).split("/")
|
||||
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
|
||||
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
|
||||
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
|
||||
} else if (color.startsWith("#")) {
|
||||
r = parseInt(color.substring(1, 3), 16)
|
||||
g = parseInt(color.substring(3, 5), 16)
|
||||
b = parseInt(color.substring(5, 7), 16)
|
||||
} else if (color.startsWith("rgb(")) {
|
||||
const parts = color.substring(4, color.length - 1).split(",")
|
||||
r = parseInt(parts[0])
|
||||
g = parseInt(parts[1])
|
||||
b = parseInt(parts[2])
|
||||
}
|
||||
|
||||
// Calculate luminance using relative luminance formula
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
|
||||
// Determine if dark or light based on luminance threshold
|
||||
resolve(luminance > 0.5 ? "light" : "dark")
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.setRawMode(true)
|
||||
process.stdin.on("data", handler)
|
||||
process.stdout.write("\x1b]11;?\x07")
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve("dark")
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
export function tui(input: {
|
||||
url: string
|
||||
sessionID?: string
|
||||
model?: string
|
||||
agent?: string
|
||||
prompt?: string
|
||||
onExit?: () => Promise<void>
|
||||
}) {
|
||||
// promise to prevent immediate exit
|
||||
return new Promise<void>(async (resolve) => {
|
||||
const mode = await getTerminalBackgroundColor()
|
||||
|
||||
const routeData: Route | undefined = input.sessionID
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: input.sessionID,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const onExit = async () => {
|
||||
await input.onExit?.()
|
||||
resolve()
|
||||
}
|
||||
|
||||
render(
|
||||
() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorComponent error={error} reset={reset} onExit={onExit} />
|
||||
)}
|
||||
>
|
||||
<ExitProvider onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider data={routeData}>
|
||||
<SDKProvider url={input.url}>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider
|
||||
initialModel={input.model}
|
||||
initialAgent={input.agent}
|
||||
initialPrompt={input.prompt}
|
||||
>
|
||||
<KeybindProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<PromptHistoryProvider>
|
||||
<App />
|
||||
</PromptHistoryProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
},
|
||||
{
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function App() {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
renderer.disableStdoutInterception()
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
const command = useCommandDialog()
|
||||
const { event } = useSDK()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const [sessionExists, setSessionExists] = createSignal(false)
|
||||
const { theme, mode, setMode } = useTheme()
|
||||
const exit = useExit()
|
||||
|
||||
useKeyboard(async (evt) => {
|
||||
if (!Installation.isLocal()) return
|
||||
if (evt.meta && evt.name === "t") {
|
||||
renderer.toggleDebugOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.meta && evt.name === "d") {
|
||||
renderer.console.toggle()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// Make sure session is valid, otherwise redirect to home
|
||||
createEffect(async () => {
|
||||
if (route.data.type === "session") {
|
||||
const data = route.data as SessionRoute
|
||||
await sync.session.sync(data.sessionID).catch(() => {
|
||||
toast.show({
|
||||
message: `Session not found: ${data.sessionID}`,
|
||||
variant: "error",
|
||||
})
|
||||
return route.navigate({ type: "home" })
|
||||
})
|
||||
setSessionExists(true)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
console.log(JSON.stringify(route.data))
|
||||
})
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
title: "Switch session",
|
||||
value: "session.list",
|
||||
keybind: "session_list",
|
||||
category: "Session",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogSessionList />)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "New session",
|
||||
value: "session.new",
|
||||
keybind: "session_new",
|
||||
category: "Session",
|
||||
onSelect: () => {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Switch model",
|
||||
value: "model.list",
|
||||
keybind: "model_list",
|
||||
category: "Agent",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogModel />)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Model cycle",
|
||||
value: "model.cycle_recent",
|
||||
keybind: "model_cycle_recent",
|
||||
category: "Agent",
|
||||
onSelect: () => {
|
||||
local.model.cycle(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Model cycle reverse",
|
||||
value: "model.cycle_recent_reverse",
|
||||
keybind: "model_cycle_recent_reverse",
|
||||
category: "Agent",
|
||||
onSelect: () => {
|
||||
local.model.cycle(-1)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Switch agent",
|
||||
value: "agent.list",
|
||||
keybind: "agent_list",
|
||||
category: "Agent",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogAgent />)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Agent cycle",
|
||||
value: "agent.cycle",
|
||||
keybind: "agent_cycle",
|
||||
category: "Agent",
|
||||
disabled: true,
|
||||
onSelect: () => {
|
||||
local.agent.move(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Agent cycle reverse",
|
||||
value: "agent.cycle.reverse",
|
||||
keybind: "agent_cycle_reverse",
|
||||
category: "Agent",
|
||||
disabled: true,
|
||||
onSelect: () => {
|
||||
local.agent.move(-1)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "View status",
|
||||
keybind: "status_view",
|
||||
value: "opencode.status",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogStatus />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Switch theme",
|
||||
value: "theme.switch",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogThemeList />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
|
||||
value: "theme.switch_mode",
|
||||
onSelect: () => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
value: "help.show",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogHelp />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Exit the app",
|
||||
value: "app.exit",
|
||||
onSelect: exit,
|
||||
category: "System",
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
const providerID = local.model.current().providerID
|
||||
if (providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
|
||||
untrack(() => {
|
||||
DialogAlert.show(
|
||||
dialog,
|
||||
"Warning",
|
||||
"While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
|
||||
).then(() => kv.set("openrouter_warning", true))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
command.trigger(evt.properties.command)
|
||||
})
|
||||
|
||||
event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||
toast.show({
|
||||
title: evt.properties.title,
|
||||
message: evt.properties.message,
|
||||
variant: evt.properties.variant,
|
||||
duration: evt.properties.duration,
|
||||
})
|
||||
})
|
||||
|
||||
event.on(SessionApi.Event.Deleted.type, (evt) => {
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||
route.navigate({ type: "home" })
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "The current session was deleted",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background}
|
||||
onMouseUp={async () => {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (text && text.length > 0) {
|
||||
const base64 = Buffer.from(text).toString("base64")
|
||||
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||
/* @ts-expect-error */
|
||||
renderer.writeOut(finalOsc52)
|
||||
await Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
renderer.clearSelection()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session" && sessionExists()}>
|
||||
<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>
|
||||
<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>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => Promise<void> }) {
|
||||
const term = useTerminalDimensions()
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
props.onExit()
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml")
|
||||
|
||||
if (props.error.message) {
|
||||
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
|
||||
}
|
||||
|
||||
if (props.error.stack) {
|
||||
issueURL.searchParams.set(
|
||||
"description",
|
||||
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
|
||||
)
|
||||
}
|
||||
|
||||
const copyIssueURL = () => {
|
||||
Clipboard.copy(issueURL.toString()).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text attributes={TextAttributes.BOLD}>Please report an issue.</text>
|
||||
<box onMouseUp={copyIssueURL} backgroundColor="#565f89" padding={1}>
|
||||
<text attributes={TextAttributes.BOLD}>Copy issue URL (exception info pre-filled)</text>
|
||||
</box>
|
||||
{copied() && <text>Successfully copied</text>}
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text>A fatal error occurred!</text>
|
||||
<box onMouseUp={props.reset} backgroundColor="#565f89" padding={1}>
|
||||
<text>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={props.onExit} backgroundColor="#565f89" padding={1}>
|
||||
<text>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox height={Math.floor(term().height * 0.7)}>
|
||||
<text>{props.error.stack}</text>
|
||||
</scrollbox>
|
||||
<text>{props.error.message}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cmd } from "../cmd"
|
||||
import { tui } from "./app"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <url>",
|
||||
describe: "attach to a running opencode server",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("url", {
|
||||
type: "string",
|
||||
describe: "http://localhost:4096",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("dir", {
|
||||
type: "string",
|
||||
description: "directory to run in",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
if (args.dir) process.chdir(args.dir)
|
||||
await tui(args)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export const SplitBorder = {
|
||||
border: ["left" as const, "right" as const],
|
||||
customBorderChars: {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "┃",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: "",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
|
||||
export function DialogAgent() {
|
||||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() =>
|
||||
local.agent.list().map((item) => {
|
||||
return {
|
||||
value: item.name,
|
||||
title: item.name,
|
||||
description: item.builtIn ? "native" : item.description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Select agent"
|
||||
current={local.agent.current().name}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
local.agent.set(option.value)
|
||||
dialog.clear()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import {
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import type { KeybindsConfig } from "@opencode-ai/sdk"
|
||||
|
||||
type Context = ReturnType<typeof init>
|
||||
const ctx = createContext<Context>()
|
||||
|
||||
export type CommandOption = DialogSelectOption & {
|
||||
keybind?: keyof KeybindsConfig
|
||||
}
|
||||
|
||||
function init() {
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
const options = createMemo(() => {
|
||||
return registrations().flatMap((x) => x())
|
||||
})
|
||||
|
||||
let keybinds = true
|
||||
useKeyboard((evt) => {
|
||||
if (!keybinds) return
|
||||
for (const option of options()) {
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
evt.preventDefault()
|
||||
option.onSelect?.(dialog)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
trigger(name: string, source?: "prompt") {
|
||||
for (const option of options()) {
|
||||
if (option.value === name) {
|
||||
option.onSelect?.(dialog, source)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
keybinds(enabled: boolean) {
|
||||
keybinds = enabled
|
||||
},
|
||||
show() {
|
||||
dialog.replace(() => <DialogCommand options={options()} />)
|
||||
},
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb)
|
||||
setRegistrations((arr) => [results, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
||||
})
|
||||
},
|
||||
get options() {
|
||||
return options()
|
||||
},
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function useCommandDialog() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useCommandDialog must be used within a CommandProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function CommandProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (keybind.match("command_list", evt) && dialog.stack.length === 0) {
|
||||
evt.preventDefault()
|
||||
dialog.replace(() => <DialogCommand options={value.options} />)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
function DialogCommand(props: { options: CommandOption[] }) {
|
||||
const keybind = useKeybind()
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Commands"
|
||||
options={props.options.map((x) => ({
|
||||
...x,
|
||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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 } from "remeda"
|
||||
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
|
||||
export function DialogModel() {
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
|
||||
|
||||
const options = createMemo(() => {
|
||||
return [
|
||||
...(!ref()?.filter
|
||||
? local.model.recent().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",
|
||||
},
|
||||
]
|
||||
})
|
||||
: []),
|
||||
...pipe(
|
||||
sync.data.provider,
|
||||
sortBy(
|
||||
(provider) => provider.id !== "opencode",
|
||||
(provider) => provider.name,
|
||||
),
|
||||
flatMap((provider) =>
|
||||
pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
map(([model, info]) => ({
|
||||
value: {
|
||||
providerID: provider.id,
|
||||
modelID: model,
|
||||
},
|
||||
title: info.name ?? model,
|
||||
description: provider.name,
|
||||
category: provider.name,
|
||||
})),
|
||||
filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
ref={setRef}
|
||||
title="Select model"
|
||||
current={local.model.current()}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
dialog.clear()
|
||||
local.model.set(option.value, { recent: true })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const route = useRoute()
|
||||
const sdk = useSDK()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
|
||||
const deleteKeybind = "ctrl+d"
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return sync.data.session
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.map((x) => {
|
||||
const date = new Date(x.time.updated)
|
||||
let category = date.toDateString()
|
||||
if (category === today) {
|
||||
category = "Today"
|
||||
}
|
||||
const isDeleting = toDelete() === x.id
|
||||
return {
|
||||
title: isDeleting ? `Press ${deleteKeybind} again to confirm` : x.title,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Sessions"
|
||||
options={options()}
|
||||
limit={50}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: Keybind.parse(deleteKeybind)[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
sdk.client.session.delete({
|
||||
path: {
|
||||
id: option.value,
|
||||
},
|
||||
})
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: Keybind.parse("ctrl+r")[0],
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { DialogPrompt } from "@tui/ui/dialog-prompt"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSDK } from "../context/sdk"
|
||||
|
||||
interface DialogSessionRenameProps {
|
||||
session: string
|
||||
}
|
||||
|
||||
export function DialogSessionRename(props: DialogSessionRenameProps) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const session = createMemo(() => sync.session.get(props.session))
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title="Rename Session"
|
||||
value={session()?.title}
|
||||
onConfirm={(value) => {
|
||||
sdk.client.session.update({
|
||||
path: {
|
||||
id: props.session,
|
||||
},
|
||||
body: {
|
||||
title: value,
|
||||
},
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
onCancel={() => dialog.clear()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||
|
||||
export type DialogStatusProps = {}
|
||||
|
||||
export function DialogStatus() {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
Status
|
||||
</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text>No MCP Servers</text>}>
|
||||
<box>
|
||||
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
|
||||
<For each={Object.entries(sync.data.mcp)}>
|
||||
{([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">
|
||||
<b>{key}</b>{" "}
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
<Switch>
|
||||
<Match when={item.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
|
||||
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
{sync.data.lsp.length > 0 && (
|
||||
<box>
|
||||
<text fg={theme.text}>{sync.data.lsp.length} LSP Servers</text>
|
||||
<For each={sync.data.lsp}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: theme.success,
|
||||
error: theme.error,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
<Show
|
||||
when={enabledFormatters().length > 0}
|
||||
fallback={<text fg={theme.text}>No Formatters</text>}
|
||||
>
|
||||
<box>
|
||||
<text fg={theme.text}>{enabledFormatters().length} Formatters</text>
|
||||
<For each={enabledFormatters()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: theme.success,
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text wrapMode="word" fg={theme.text}>
|
||||
<b>{item.name}</b>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
|
||||
const [store] = createStore({
|
||||
filter: "",
|
||||
})
|
||||
|
||||
const [files] = createResource(
|
||||
() => [store.filter],
|
||||
async () => {
|
||||
const result = await sdk.client.find.files({
|
||||
query: {
|
||||
query: store.filter,
|
||||
},
|
||||
})
|
||||
if (result.error) return []
|
||||
const sliced = (result.data ?? []).slice(0, 5)
|
||||
return sliced
|
||||
},
|
||||
)
|
||||
|
||||
const options = createMemo(() =>
|
||||
(files() ?? []).map((file) => ({
|
||||
value: file,
|
||||
title: file,
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Autocomplete"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
props.onSelect?.(option.value)
|
||||
dialog.clear()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
||||
import { THEMES, useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
|
||||
export function DialogThemeList() {
|
||||
const theme = useTheme()
|
||||
const options = Object.keys(THEMES).map((value) => ({
|
||||
title: value,
|
||||
value: value as keyof typeof THEMES,
|
||||
}))
|
||||
const dialog = useDialog()
|
||||
let confirmed = false
|
||||
let ref: DialogSelectRef<keyof typeof THEMES>
|
||||
const initial = theme.selected
|
||||
|
||||
onMount(() => {
|
||||
// highlight the first theme in the list when we open it for UX
|
||||
theme.set(Object.keys(THEMES)[0] as keyof typeof THEMES)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
// if we close the dialog without confirming, reset back to the initial theme
|
||||
if (!confirmed) theme.set(initial)
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Themes"
|
||||
options={options}
|
||||
onMove={(opt) => {
|
||||
theme.set(opt.value)
|
||||
}}
|
||||
onSelect={(opt) => {
|
||||
theme.set(opt.value)
|
||||
confirmed = true
|
||||
dialog.clear()
|
||||
}}
|
||||
ref={(r) => {
|
||||
ref = r
|
||||
}}
|
||||
onFilter={(query) => {
|
||||
if (query.length === 0) {
|
||||
theme.set(initial)
|
||||
return
|
||||
}
|
||||
|
||||
const first = ref.filtered[0]
|
||||
if (first) theme.set(first.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Installation } from "@/installation"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { For } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
|
||||
const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`]
|
||||
|
||||
const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
|
||||
|
||||
export function Logo() {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box>
|
||||
<For each={LOGO_LEFT}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>{line}</text>
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
{LOGO_RIGHT[index()]}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<box flexDirection="row" justifyContent="flex-end">
|
||||
<text fg={theme.textMuted}>{Installation.VERSION}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
import type { BoxRenderable, TextareaRenderable, KeyEvent } from "@opentui/core"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { firstBy } from "remeda"
|
||||
import { createMemo, createResource, createEffect, onMount, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { PromptInfo } from "./history"
|
||||
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
onKeyDown: (e: KeyEvent) => void
|
||||
visible: false | "@" | "/"
|
||||
}
|
||||
|
||||
export type AutocompleteOption = {
|
||||
display: string
|
||||
aliases?: string[]
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
export function Autocomplete(props: {
|
||||
value: string
|
||||
sessionID?: string
|
||||
setPrompt: (input: (prompt: PromptInfo) => void) => void
|
||||
setExtmark: (partIndex: number, extmarkId: number) => void
|
||||
anchor: () => BoxRenderable
|
||||
input: () => TextareaRenderable
|
||||
ref: (ref: AutocompleteRef) => void
|
||||
fileStyleId: number
|
||||
agentStyleId: number
|
||||
promptPartTypeId: () => number
|
||||
}) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const command = useCommandDialog()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
selected: 0,
|
||||
visible: false as AutocompleteRef["visible"],
|
||||
position: { x: 0, y: 0, width: 0 },
|
||||
})
|
||||
const filter = createMemo(() => {
|
||||
if (!store.visible) return
|
||||
// Track props.value to make memo reactive to text changes
|
||||
props.value // <- there surely is a better way to do this, like making .input() reactive
|
||||
|
||||
const val = props.input().getTextRange(store.index + 1, props.input().visualCursor.offset + 1)
|
||||
|
||||
return val
|
||||
})
|
||||
|
||||
function insertPart(text: string, part: PromptInfo["parts"][number]) {
|
||||
const input = props.input()
|
||||
const currentCursorOffset = input.visualCursor.offset
|
||||
|
||||
const charAfterCursor = props.value.at(currentCursorOffset)
|
||||
const needsSpace = charAfterCursor !== " "
|
||||
const append = "@" + text + (needsSpace ? " " : "")
|
||||
|
||||
input.cursorOffset = store.index
|
||||
const startCursor = input.logicalCursor
|
||||
input.cursorOffset = currentCursorOffset
|
||||
const endCursor = input.logicalCursor
|
||||
|
||||
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
|
||||
input.insertText(append)
|
||||
|
||||
const virtualText = "@" + text
|
||||
const extmarkStart = store.index
|
||||
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
|
||||
|
||||
const styleId =
|
||||
part.type === "file"
|
||||
? props.fileStyleId
|
||||
: part.type === "agent"
|
||||
? props.agentStyleId
|
||||
: undefined
|
||||
|
||||
const extmarkId = input.extmarks.create({
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
virtual: true,
|
||||
styleId,
|
||||
typeId: props.promptPartTypeId(),
|
||||
})
|
||||
|
||||
props.setPrompt((draft) => {
|
||||
if (part.type === "file" && part.source?.text) {
|
||||
part.source.text.start = extmarkStart
|
||||
part.source.text.end = extmarkEnd
|
||||
part.source.text.value = virtualText
|
||||
} else if (part.type === "agent" && part.source) {
|
||||
part.source.start = extmarkStart
|
||||
part.source.end = extmarkEnd
|
||||
part.source.value = virtualText
|
||||
}
|
||||
const partIndex = draft.parts.length
|
||||
draft.parts.push(part)
|
||||
props.setExtmark(partIndex, extmarkId)
|
||||
})
|
||||
}
|
||||
|
||||
const [files] = createResource(
|
||||
() => filter(),
|
||||
async (query) => {
|
||||
if (!store.visible || store.visible === "/") return []
|
||||
|
||||
// Get files from SDK
|
||||
const result = await sdk.client.find.files({
|
||||
query: {
|
||||
query: query ?? "",
|
||||
},
|
||||
})
|
||||
|
||||
const options: AutocompleteOption[] = []
|
||||
|
||||
// Add file options
|
||||
if (!result.error && result.data) {
|
||||
options.push(
|
||||
...result.data.map(
|
||||
(item): AutocompleteOption => ({
|
||||
display: item,
|
||||
onSelect: () => {
|
||||
insertPart(item, {
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: item,
|
||||
url: `file://${process.cwd()}/${item}`,
|
||||
source: {
|
||||
type: "file",
|
||||
text: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
path: item,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
},
|
||||
{
|
||||
initialValue: [],
|
||||
},
|
||||
)
|
||||
|
||||
const agents = createMemo(() => {
|
||||
if (store.index !== 0) return []
|
||||
const agents = sync.data.agent
|
||||
return agents
|
||||
.filter((agent) => !agent.builtIn && agent.mode !== "primary")
|
||||
.map(
|
||||
(agent): AutocompleteOption => ({
|
||||
display: "@" + agent.name,
|
||||
onSelect: () => {
|
||||
insertPart(agent.name, {
|
||||
type: "agent",
|
||||
name: agent.name,
|
||||
source: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const session = createMemo(() =>
|
||||
props.sessionID ? sync.session.get(props.sessionID) : undefined,
|
||||
)
|
||||
const commands = createMemo((): AutocompleteOption[] => {
|
||||
const results: AutocompleteOption[] = []
|
||||
const s = session()
|
||||
for (const command of sync.data.command) {
|
||||
results.push({
|
||||
display: "/" + command.name,
|
||||
description: command.description,
|
||||
onSelect: () => {
|
||||
const newText = "/" + command.name + " "
|
||||
const cursor = props.input().logicalCursor
|
||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||
props.input().insertText(newText)
|
||||
props.input().cursorOffset = Bun.stringWidth(newText)
|
||||
},
|
||||
})
|
||||
}
|
||||
if (s) {
|
||||
results.push(
|
||||
{
|
||||
display: "/undo",
|
||||
description: "undo the last message",
|
||||
onSelect: () => command.trigger("session.undo"),
|
||||
},
|
||||
{
|
||||
display: "/redo",
|
||||
description: "redo the last message",
|
||||
onSelect: () => command.trigger("session.redo"),
|
||||
},
|
||||
{
|
||||
display: "/compact",
|
||||
aliases: ["/summarize"],
|
||||
description: "compact the session",
|
||||
onSelect: () => command.trigger("session.compact"),
|
||||
},
|
||||
{
|
||||
display: "/share",
|
||||
disabled: !!s.share?.url,
|
||||
description: "share a session",
|
||||
onSelect: () => command.trigger("session.share"),
|
||||
},
|
||||
{
|
||||
display: "/unshare",
|
||||
disabled: !s.share,
|
||||
description: "unshare a session",
|
||||
onSelect: () => command.trigger("session.unshare"),
|
||||
},
|
||||
{
|
||||
display: "/rename",
|
||||
description: "rename session",
|
||||
onSelect: () => command.trigger("session.rename"),
|
||||
},
|
||||
{
|
||||
display: "/timeline",
|
||||
description: "jump to message",
|
||||
onSelect: () => command.trigger("session.timeline"),
|
||||
},
|
||||
)
|
||||
}
|
||||
results.push(
|
||||
{
|
||||
display: "/new",
|
||||
aliases: ["/clear"],
|
||||
description: "create a new session",
|
||||
onSelect: () => command.trigger("session.new"),
|
||||
},
|
||||
{
|
||||
display: "/models",
|
||||
description: "list models",
|
||||
onSelect: () => command.trigger("model.list"),
|
||||
},
|
||||
{
|
||||
display: "/agents",
|
||||
description: "list agents",
|
||||
onSelect: () => command.trigger("agent.list"),
|
||||
},
|
||||
{
|
||||
display: "/session",
|
||||
aliases: ["/resume", "/continue"],
|
||||
description: "list sessions",
|
||||
onSelect: () => command.trigger("session.list"),
|
||||
},
|
||||
{
|
||||
display: "/status",
|
||||
description: "show status",
|
||||
onSelect: () => command.trigger("opencode.status"),
|
||||
},
|
||||
{
|
||||
display: "/theme",
|
||||
description: "toggle theme",
|
||||
onSelect: () => command.trigger("theme.switch"),
|
||||
},
|
||||
{
|
||||
display: "/editor",
|
||||
description: "open editor",
|
||||
onSelect: () => command.trigger("prompt.editor", "prompt"),
|
||||
},
|
||||
{
|
||||
display: "/help",
|
||||
description: "show help",
|
||||
onSelect: () => command.trigger("help.show"),
|
||||
},
|
||||
{
|
||||
display: "/commands",
|
||||
description: "show all commands",
|
||||
onSelect: () => command.show(),
|
||||
},
|
||||
{
|
||||
display: "/exit",
|
||||
aliases: ["/quit", "/q"],
|
||||
description: "exit the app",
|
||||
onSelect: () => command.trigger("app.exit"),
|
||||
},
|
||||
)
|
||||
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
|
||||
if (!max) return results
|
||||
return results.map((item) => ({
|
||||
...item,
|
||||
display: item.display.padEnd(max + 2),
|
||||
}))
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const mixed: AutocompleteOption[] = (
|
||||
store.visible === "@"
|
||||
? [...agents(), ...(files.loading ? files.latest || [] : files())]
|
||||
: [...commands()]
|
||||
).filter((x) => x.disabled !== true)
|
||||
const currentFilter = filter()
|
||||
if (!currentFilter) return mixed.slice(0, 10)
|
||||
const result = fuzzysort.go(currentFilter, mixed, {
|
||||
keys: ["display", "description", (obj) => obj.aliases?.join(" ") ?? ""],
|
||||
limit: 10,
|
||||
})
|
||||
return result.map((arr) => arr.obj)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
filter()
|
||||
setStore("selected", 0)
|
||||
})
|
||||
|
||||
function move(direction: -1 | 1) {
|
||||
if (!store.visible) return
|
||||
if (!options().length) return
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = options().length - 1
|
||||
if (next >= options().length) next = 0
|
||||
setStore("selected", next)
|
||||
}
|
||||
|
||||
function select() {
|
||||
const selected = options()[store.selected]
|
||||
if (!selected) return
|
||||
selected.onSelect?.()
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(mode: "@" | "/") {
|
||||
command.keybinds(false)
|
||||
setStore({
|
||||
visible: mode,
|
||||
index: props.input().visualCursor.offset,
|
||||
position: {
|
||||
x: props.anchor().x,
|
||||
y: props.anchor().y,
|
||||
width: props.anchor().width,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
const text = props.input().plainText
|
||||
if (store.visible === "/" && !text.endsWith(" ")) {
|
||||
const cursor = props.input().logicalCursor
|
||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||
}
|
||||
command.keybinds(true)
|
||||
setStore("visible", false)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
props.ref({
|
||||
get visible() {
|
||||
return store.visible
|
||||
},
|
||||
onInput(value: string) {
|
||||
if (store.visible && Bun.stringWidth(value) <= store.index) hide()
|
||||
},
|
||||
onKeyDown(e: KeyEvent) {
|
||||
if (store.visible) {
|
||||
if (e.name === "up") move(-1)
|
||||
if (e.name === "down") move(1)
|
||||
if (e.name === "escape") hide()
|
||||
if (e.name === "return" || e.name === "tab") select()
|
||||
if (["up", "down", "return", "tab", "escape"].includes(e.name)) e.preventDefault()
|
||||
}
|
||||
if (!store.visible) {
|
||||
if (e.name === "@") {
|
||||
const cursorOffset = props.input().visualCursor.offset
|
||||
const charBeforeCursor =
|
||||
cursorOffset === 0
|
||||
? undefined
|
||||
: props.input().getTextRange(cursorOffset - 1, cursorOffset)
|
||||
|
||||
if (
|
||||
charBeforeCursor === " " ||
|
||||
charBeforeCursor === "\n" ||
|
||||
charBeforeCursor === undefined
|
||||
) {
|
||||
show("@")
|
||||
}
|
||||
}
|
||||
|
||||
if (e.name === "/") {
|
||||
if (props.input().visualCursor.offset === 0) show("/")
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const height = createMemo(() => {
|
||||
if (options().length) return Math.min(10, options().length)
|
||||
return 1
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
visible={store.visible !== false}
|
||||
position="absolute"
|
||||
top={store.position.y - height()}
|
||||
left={store.position.x}
|
||||
width={store.position.width}
|
||||
zIndex={100}
|
||||
{...SplitBorder}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<box backgroundColor={theme.backgroundElement} height={height()}>
|
||||
<For
|
||||
each={options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text>No matching items</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(option, index) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index() === store.selected ? theme.primary : undefined}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={index() === store.selected ? theme.background : theme.text} flexShrink={0}>
|
||||
{option.display}
|
||||
</text>
|
||||
<Show when={option.description}>
|
||||
<text
|
||||
fg={index() === store.selected ? theme.background : theme.textMuted}
|
||||
wrapMode="none"
|
||||
>
|
||||
{option.description}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import path from "path"
|
||||
import { Global } from "@/global"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { clone } from "remeda"
|
||||
import { createSimpleContext } from "../../context/helper"
|
||||
import { appendFile } from "fs/promises"
|
||||
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk"
|
||||
|
||||
export type PromptInfo = {
|
||||
input: string
|
||||
parts: (
|
||||
| Omit<FilePart, "id" | "messageID" | "sessionID">
|
||||
| Omit<AgentPart, "id" | "messageID" | "sessionID">
|
||||
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
|
||||
source?: {
|
||||
text: {
|
||||
start: number
|
||||
end: number
|
||||
value: string
|
||||
}
|
||||
}
|
||||
})
|
||||
)[]
|
||||
}
|
||||
|
||||
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
|
||||
name: "PromptHistory",
|
||||
init: () => {
|
||||
const historyFile = Bun.file(path.join(Global.Path.state, "prompt-history.jsonl"))
|
||||
onMount(async () => {
|
||||
const text = await historyFile.text().catch(() => "")
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
setStore("history", lines as PromptInfo[])
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
history: [] as PromptInfo[],
|
||||
})
|
||||
|
||||
return {
|
||||
move(direction: 1 | -1, input: string) {
|
||||
if (!store.history.length) return undefined
|
||||
const current = store.history.at(store.index)
|
||||
if (!current) return undefined
|
||||
if (current.input !== input && input.length) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const next = store.index + direction
|
||||
if (Math.abs(next) > store.history.length) return
|
||||
if (next > 0) return
|
||||
draft.index = next
|
||||
}),
|
||||
)
|
||||
if (store.index === 0)
|
||||
return {
|
||||
input: "",
|
||||
parts: [],
|
||||
}
|
||||
return store.history.at(store.index)
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
item = clone(item)
|
||||
appendFile(historyFile.name!, JSON.stringify(item) + "\n")
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.history.push(item)
|
||||
draft.index = 0
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,723 @@
|
||||
import {
|
||||
TextAttributes,
|
||||
BoxRenderable,
|
||||
TextareaRenderable,
|
||||
MouseEvent,
|
||||
KeyEvent,
|
||||
PasteEvent,
|
||||
t,
|
||||
dim,
|
||||
fg,
|
||||
} from "@opentui/core"
|
||||
import { createEffect, createMemo, Match, Switch, type JSX, onMount, batch } from "solid-js"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { usePromptHistory, type PromptInfo } from "./history"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useCommandDialog } from "../dialog-command"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { Editor } from "@tui/util/editor"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Clipboard } from "../../util/clipboard"
|
||||
import type { FilePart } from "@opencode-ai/sdk"
|
||||
import { TuiEvent } from "../../event"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
disabled?: boolean
|
||||
onSubmit?: () => void
|
||||
ref?: (ref: PromptRef) => void
|
||||
hint?: JSX.Element
|
||||
showPlaceholder?: boolean
|
||||
}
|
||||
|
||||
export type PromptRef = {
|
||||
focused: boolean
|
||||
set(prompt: PromptInfo): void
|
||||
reset(): void
|
||||
blur(): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
export function Prompt(props: PromptProps) {
|
||||
let input: TextareaRenderable
|
||||
let anchor: BoxRenderable
|
||||
let autocomplete: AutocompleteRef
|
||||
|
||||
const keybind = useKeybind()
|
||||
const local = useLocal()
|
||||
const sdk = useSDK()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle"))
|
||||
const history = usePromptHistory()
|
||||
const command = useCommandDialog()
|
||||
const renderer = useRenderer()
|
||||
const { theme, syntax } = useTheme()
|
||||
|
||||
const textareaKeybindings = createMemo(() => {
|
||||
const newlineBindings = keybind.all.input_newline || []
|
||||
const submitBindings = keybind.all.input_submit || []
|
||||
|
||||
return [
|
||||
{ name: "return", action: "submit" },
|
||||
{ name: "return", meta: true, action: "newline" },
|
||||
...newlineBindings.map((binding) => ({
|
||||
name: binding.name,
|
||||
ctrl: binding.ctrl || undefined,
|
||||
meta: binding.meta || undefined,
|
||||
shift: binding.shift || undefined,
|
||||
action: "newline" as const,
|
||||
})),
|
||||
...submitBindings.map((binding) => ({
|
||||
name: binding.name,
|
||||
ctrl: binding.ctrl || undefined,
|
||||
meta: binding.meta || undefined,
|
||||
shift: binding.shift || undefined,
|
||||
action: "submit" as const,
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
const fileStyleId = syntax().getStyleId("extmark.file")!
|
||||
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
||||
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
||||
let promptPartTypeId: number
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: "Open editor",
|
||||
category: "Session",
|
||||
keybind: "editor_open",
|
||||
value: "prompt.editor",
|
||||
onSelect: async (dialog, trigger) => {
|
||||
dialog.clear()
|
||||
const value = trigger === "prompt" ? "" : input.plainText
|
||||
const content = await Editor.open({ value, renderer })
|
||||
if (content) {
|
||||
input.setText(content, { history: false })
|
||||
setStore("prompt", {
|
||||
input: content,
|
||||
parts: [],
|
||||
})
|
||||
input.cursorOffset = Bun.stringWidth(content)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
disabled: true,
|
||||
category: "Prompt",
|
||||
onSelect: (dialog) => {
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Submit prompt",
|
||||
value: "prompt.submit",
|
||||
disabled: true,
|
||||
keybind: "input_submit",
|
||||
category: "Prompt",
|
||||
onSelect: (dialog) => {
|
||||
if (!input.focused) return
|
||||
submit()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Paste",
|
||||
value: "prompt.paste",
|
||||
disabled: true,
|
||||
keybind: "input_paste",
|
||||
category: "Prompt",
|
||||
onSelect: async () => {
|
||||
const content = await Clipboard.read()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
await pasteImage({
|
||||
filename: "clipboard",
|
||||
mime: content.mime,
|
||||
content: content.data,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||
setStore(
|
||||
"prompt",
|
||||
produce((draft) => {
|
||||
draft.input += evt.properties.text
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.disabled) input.cursorColor = theme.backgroundElement
|
||||
if (!props.disabled) input.cursorColor = theme.primary
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
prompt: PromptInfo
|
||||
mode: "normal" | "shell"
|
||||
extmarkToPartIndex: Map<number, number>
|
||||
}>({
|
||||
prompt: {
|
||||
input: "",
|
||||
parts: [],
|
||||
},
|
||||
mode: "normal",
|
||||
extmarkToPartIndex: new Map(),
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.focus()
|
||||
})
|
||||
|
||||
local.setInitialPrompt.listen((initialPrompt) => {
|
||||
batch(() => {
|
||||
setStore("prompt", {
|
||||
input: initialPrompt,
|
||||
parts: [],
|
||||
})
|
||||
input.insertText(initialPrompt)
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
||||
})
|
||||
|
||||
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
||||
input.extmarks.clear()
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
|
||||
parts.forEach((part, partIndex) => {
|
||||
let start = 0
|
||||
let end = 0
|
||||
let virtualText = ""
|
||||
let styleId: number | undefined
|
||||
|
||||
if (part.type === "file" && part.source?.text) {
|
||||
start = part.source.text.start
|
||||
end = part.source.text.end
|
||||
virtualText = part.source.text.value
|
||||
styleId = fileStyleId
|
||||
} else if (part.type === "agent" && part.source) {
|
||||
start = part.source.start
|
||||
end = part.source.end
|
||||
virtualText = part.source.value
|
||||
styleId = agentStyleId
|
||||
} else if (part.type === "text" && part.source?.text) {
|
||||
start = part.source.text.start
|
||||
end = part.source.text.end
|
||||
virtualText = part.source.text.value
|
||||
styleId = pasteStyleId
|
||||
}
|
||||
|
||||
if (virtualText) {
|
||||
const extmarkId = input.extmarks.create({
|
||||
start,
|
||||
end,
|
||||
virtual: true,
|
||||
styleId,
|
||||
typeId: promptPartTypeId,
|
||||
})
|
||||
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
||||
const newMap = new Map(map)
|
||||
newMap.set(extmarkId, partIndex)
|
||||
return newMap
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function syncExtmarksWithPromptParts() {
|
||||
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const newMap = new Map<number, number>()
|
||||
const newParts: typeof draft.prompt.parts = []
|
||||
|
||||
for (const extmark of allExtmarks) {
|
||||
const partIndex = draft.extmarkToPartIndex.get(extmark.id)
|
||||
if (partIndex !== undefined) {
|
||||
const part = draft.prompt.parts[partIndex]
|
||||
if (part) {
|
||||
if (part.type === "agent" && part.source) {
|
||||
part.source.start = extmark.start
|
||||
part.source.end = extmark.end
|
||||
} else if (part.type === "file" && part.source?.text) {
|
||||
part.source.text.start = extmark.start
|
||||
part.source.text.end = extmark.end
|
||||
} else if (part.type === "text" && part.source?.text) {
|
||||
part.source.text.start = extmark.start
|
||||
part.source.text.end = extmark.end
|
||||
}
|
||||
newMap.set(extmark.id, newParts.length)
|
||||
newParts.push(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draft.extmarkToPartIndex = newMap
|
||||
draft.prompt.parts = newParts
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
props.ref?.({
|
||||
get focused() {
|
||||
return input.focused
|
||||
},
|
||||
focus() {
|
||||
input.focus()
|
||||
},
|
||||
blur() {
|
||||
input.blur()
|
||||
},
|
||||
set(prompt) {
|
||||
input.setText(prompt.input, { history: false })
|
||||
setStore("prompt", prompt)
|
||||
restoreExtmarksFromParts(prompt.parts)
|
||||
input.gotoBufferEnd()
|
||||
},
|
||||
reset() {
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
},
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (props.disabled) return
|
||||
if (autocomplete.visible) return
|
||||
if (!store.prompt.input) return
|
||||
const sessionID = props.sessionID
|
||||
? props.sessionID
|
||||
: await (async () => {
|
||||
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
|
||||
return sessionID
|
||||
})()
|
||||
const messageID = Identifier.ascending("message")
|
||||
let inputText = store.prompt.input
|
||||
|
||||
// Expand pasted text inline before submitting
|
||||
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
|
||||
const sortedExtmarks = allExtmarks.sort(
|
||||
(a: { start: number }, b: { start: number }) => b.start - a.start,
|
||||
)
|
||||
|
||||
for (const extmark of sortedExtmarks) {
|
||||
const partIndex = store.extmarkToPartIndex.get(extmark.id)
|
||||
if (partIndex !== undefined) {
|
||||
const part = store.prompt.parts[partIndex]
|
||||
if (part?.type === "text" && part.text) {
|
||||
const before = inputText.slice(0, extmark.start)
|
||||
const after = inputText.slice(extmark.end)
|
||||
inputText = before + part.text + after
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out text parts (pasted content) since they're now expanded inline
|
||||
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
|
||||
|
||||
if (store.mode === "shell") {
|
||||
sdk.client.session.shell({
|
||||
path: {
|
||||
id: sessionID,
|
||||
},
|
||||
body: {
|
||||
agent: local.agent.current().name,
|
||||
command: inputText,
|
||||
},
|
||||
})
|
||||
setStore("mode", "normal")
|
||||
} else if (inputText.startsWith("/")) {
|
||||
const [command, ...args] = inputText.split(" ")
|
||||
sdk.client.session.command({
|
||||
path: {
|
||||
id: sessionID,
|
||||
},
|
||||
body: {
|
||||
command: command.slice(1),
|
||||
arguments: args.join(" "),
|
||||
agent: local.agent.current().name,
|
||||
model: `${local.model.current().providerID}/${local.model.current().modelID}`,
|
||||
messageID,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
sdk.client.session.prompt({
|
||||
path: {
|
||||
id: sessionID,
|
||||
},
|
||||
body: {
|
||||
...local.model.current(),
|
||||
messageID,
|
||||
agent: local.agent.current().name,
|
||||
model: local.model.current(),
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: inputText,
|
||||
},
|
||||
...nonTextParts.map((x) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
...x,
|
||||
})),
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
history.append(store.prompt)
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
props.onSubmit?.()
|
||||
|
||||
// temporary hack to make sure the message is sent
|
||||
if (!props.sessionID)
|
||||
setTimeout(() => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
}, 50)
|
||||
input.clear()
|
||||
}
|
||||
const exit = useExit()
|
||||
|
||||
async function pasteImage(file: { filename?: string; content: string; mime: string }) {
|
||||
const currentOffset = input.visualCursor.offset
|
||||
const extmarkStart = currentOffset
|
||||
const count = store.prompt.parts.filter((x) => x.type === "file").length
|
||||
const virtualText = `[Image ${count + 1}]`
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
const textToInsert = virtualText + " "
|
||||
|
||||
input.insertText(textToInsert)
|
||||
|
||||
const extmarkId = input.extmarks.create({
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
virtual: true,
|
||||
styleId: pasteStyleId,
|
||||
typeId: promptPartTypeId,
|
||||
})
|
||||
|
||||
const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
|
||||
type: "file" as const,
|
||||
mime: file.mime,
|
||||
filename: file.filename,
|
||||
url: `data:${file.mime};base64,${file.content}`,
|
||||
source: {
|
||||
type: "file",
|
||||
path: file.filename ?? "",
|
||||
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)
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Autocomplete
|
||||
sessionID={props.sessionID}
|
||||
ref={(r) => (autocomplete = r)}
|
||||
anchor={() => anchor}
|
||||
input={() => input}
|
||||
setPrompt={(cb) => {
|
||||
setStore("prompt", produce(cb))
|
||||
}}
|
||||
setExtmark={(partIndex, extmarkId) => {
|
||||
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
||||
const newMap = new Map(map)
|
||||
newMap.set(extmarkId, partIndex)
|
||||
return newMap
|
||||
})
|
||||
}}
|
||||
value={store.prompt.input}
|
||||
fileStyleId={fileStyleId}
|
||||
agentStyleId={agentStyleId}
|
||||
promptPartTypeId={() => promptPartTypeId}
|
||||
/>
|
||||
<box ref={(r) => (anchor = r)}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
{...SplitBorder}
|
||||
borderColor={
|
||||
keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border
|
||||
}
|
||||
justifyContent="space-evenly"
|
||||
>
|
||||
<box
|
||||
backgroundColor={theme.backgroundElement}
|
||||
width={3}
|
||||
height="100%"
|
||||
alignItems="center"
|
||||
paddingTop={1}
|
||||
>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.primary}>
|
||||
{store.mode === "normal" ? ">" : "!"}
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
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
|
||||
}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
onContentChange={() => {
|
||||
const value = input.plainText
|
||||
setStore("prompt", "input", value)
|
||||
autocomplete.onInput(value)
|
||||
syncExtmarksWithPromptParts()
|
||||
}}
|
||||
keyBindings={textareaKeybindings()}
|
||||
onKeyDown={async (e: KeyEvent) => {
|
||||
if (props.disabled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
return
|
||||
}
|
||||
if (keybind.match("input_forward_delete", e) && store.prompt.input !== "") {
|
||||
const cursorOffset = input.cursorOffset
|
||||
if (cursorOffset < input.plainText.length) {
|
||||
const text = input.plainText
|
||||
const newText = text.slice(0, cursorOffset) + text.slice(cursorOffset + 1)
|
||||
input.setText(newText)
|
||||
input.cursorOffset = cursorOffset
|
||||
}
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (keybind.match("app_exit", e)) {
|
||||
await exit()
|
||||
return
|
||||
}
|
||||
if (e.name === "!" && input.visualCursor.offset === 0) {
|
||||
setStore("mode", "shell")
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (store.mode === "shell") {
|
||||
if (
|
||||
(e.name === "backspace" && input.visualCursor.offset === 0) ||
|
||||
e.name === "escape"
|
||||
) {
|
||||
setStore("mode", "normal")
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (store.mode === "normal") autocomplete.onKeyDown(e)
|
||||
if (!autocomplete.visible) {
|
||||
if (
|
||||
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
|
||||
(keybind.match("history_next", e) &&
|
||||
input.cursorOffset === input.plainText.length)
|
||||
) {
|
||||
const direction = keybind.match("history_previous", e) ? -1 : 1
|
||||
const item = history.move(direction, input.plainText)
|
||||
|
||||
if (item) {
|
||||
input.setText(item.input, { history: false })
|
||||
setStore("prompt", item)
|
||||
restoreExtmarksFromParts(item.parts)
|
||||
e.preventDefault()
|
||||
if (direction === -1) input.cursorOffset = 0
|
||||
if (direction === 1) input.cursorOffset = input.plainText.length
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.name === "up" && input.visualCursor.visualRow === 0) input.cursorOffset = 0
|
||||
if (e.name === "down" && input.visualCursor.visualRow === input.height - 1)
|
||||
input.cursorOffset = input.plainText.length
|
||||
}
|
||||
if (!autocomplete.visible) {
|
||||
if (keybind.match("session_interrupt", e) && props.sessionID) {
|
||||
sdk.client.session.abort({
|
||||
path: {
|
||||
id: props.sessionID,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSubmit={submit}
|
||||
onPaste={async (event: PasteEvent) => {
|
||||
if (props.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const pastedContent = event.text.trim()
|
||||
if (!pastedContent) {
|
||||
command.trigger("prompt.paste")
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if (lineCount >= 5) {
|
||||
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)
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
}}
|
||||
ref={(r: TextareaRenderable) => (input = r)}
|
||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||
focusedBackgroundColor={theme.backgroundElement}
|
||||
cursorColor={theme.primary}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={theme.backgroundElement}
|
||||
width={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
></box>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text flexShrink={0} wrapMode="none" fg={theme.text}>
|
||||
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
|
||||
<span style={{ bold: true }}>{local.model.parsed().model}</span>
|
||||
</text>
|
||||
<Switch>
|
||||
<Match when={status() === "compacting"}>
|
||||
<text fg={theme.textMuted}>compacting...</text>
|
||||
</Match>
|
||||
<Match when={status() === "working"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={props.hint}>{props.hint!}</Match>
|
||||
<Match when={true}>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
name: "Exit",
|
||||
init: (input: { onExit?: () => Promise<void> }) => {
|
||||
const renderer = useRenderer()
|
||||
return async () => {
|
||||
renderer.destroy()
|
||||
await input.onExit?.()
|
||||
process.exit(0)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
|
||||
name: string
|
||||
init: ((input: Props) => T) | (() => T)
|
||||
}) {
|
||||
const ctx = createContext<T>()
|
||||
|
||||
return {
|
||||
provider: (props: ParentProps<Props>) => {
|
||||
const init = input.init(props)
|
||||
return (
|
||||
// @ts-expect-error
|
||||
<Show when={init.ready === undefined || init.ready === true}>
|
||||
<ctx.Provider value={init}>{props.children}</ctx.Provider>
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
use() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
|
||||
return value
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { pipe, mapValues } from "remeda"
|
||||
import type { KeybindsConfig } from "@opencode-ai/sdk"
|
||||
import type { ParsedKey, Renderable } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
|
||||
name: "Keybind",
|
||||
init: () => {
|
||||
const sync = useSync()
|
||||
const keybinds = createMemo(() => {
|
||||
return pipe(
|
||||
sync.data.config.keybinds ?? {},
|
||||
mapValues((value) => Keybind.parse(value)),
|
||||
)
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
leader: false,
|
||||
})
|
||||
const renderer = useRenderer()
|
||||
|
||||
let focus: Renderable | null
|
||||
let timeout: NodeJS.Timeout
|
||||
function leader(active: boolean) {
|
||||
if (active) {
|
||||
setStore("leader", true)
|
||||
focus = renderer.currentFocusedRenderable
|
||||
focus?.blur()
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
if (!store.leader) return
|
||||
leader(false)
|
||||
if (focus) {
|
||||
focus.focus()
|
||||
}
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
if (focus && !renderer.currentFocusedRenderable) {
|
||||
focus.focus()
|
||||
}
|
||||
setStore("leader", false)
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(async (evt) => {
|
||||
if (!store.leader && result.match("leader", evt)) {
|
||||
leader(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.leader && evt.name) {
|
||||
setImmediate(() => {
|
||||
if (focus && renderer.currentFocusedRenderable === focus) {
|
||||
focus.focus()
|
||||
}
|
||||
leader(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
get all() {
|
||||
return keybinds()
|
||||
},
|
||||
get leader() {
|
||||
return store.leader
|
||||
},
|
||||
parse(evt: ParsedKey): Keybind.Info {
|
||||
if (evt.name === "\x1F")
|
||||
return {
|
||||
ctrl: true,
|
||||
name: "_",
|
||||
shift: false,
|
||||
leader: false,
|
||||
meta: false,
|
||||
}
|
||||
return {
|
||||
ctrl: evt.ctrl,
|
||||
name: evt.name,
|
||||
shift: evt.shift,
|
||||
leader: store.leader,
|
||||
meta: evt.meta,
|
||||
}
|
||||
},
|
||||
match(key: keyof KeybindsConfig, evt: ParsedKey) {
|
||||
const keybind = keybinds()[key]
|
||||
if (!keybind) return false
|
||||
const parsed: Keybind.Info = result.parse(evt)
|
||||
for (const key of keybind) {
|
||||
if (Keybind.match(key, parsed)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
},
|
||||
print(key: keyof KeybindsConfig) {
|
||||
const first = keybinds()[key]?.at(0)
|
||||
if (!first) return ""
|
||||
const result = Keybind.toString(first)
|
||||
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Global } from "@/global"
|
||||
import { createSignal, type Setter } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import path from "path"
|
||||
|
||||
export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
name: "KV",
|
||||
init: () => {
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [kvStore, setKvStore] = createStore<Record<string, any>>()
|
||||
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
|
||||
|
||||
file
|
||||
.json()
|
||||
.then((x) => {
|
||||
setKvStore(x)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setReady(true)
|
||||
})
|
||||
|
||||
const result = {
|
||||
get ready() {
|
||||
return ready()
|
||||
},
|
||||
signal<T>(name: string, defaultValue: T) {
|
||||
if (!kvStore[name]) setKvStore(name, defaultValue)
|
||||
return [
|
||||
function () {
|
||||
return result.get(name)
|
||||
},
|
||||
function setter(next: Setter<T>) {
|
||||
result.set(name, next)
|
||||
},
|
||||
] as const
|
||||
},
|
||||
get(key: string, defaultValue?: any) {
|
||||
return kvStore[key] ?? defaultValue
|
||||
},
|
||||
set(key: string, value: any) {
|
||||
setKvStore(key, value)
|
||||
Bun.write(file, JSON.stringify(kvStore, null, 2))
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { uniqueBy } from "remeda"
|
||||
import path from "path"
|
||||
import { Global } from "@/global"
|
||||
import { iife } from "@/util/iife"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { createEventBus } from "@solid-primitives/event-bus"
|
||||
import { Provider } from "@/provider/provider"
|
||||
|
||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: (props: { initialModel?: string; initialAgent?: string; initialPrompt?: string }) => {
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
|
||||
function isModelValid(model: { providerID: string; modelID: string }) {
|
||||
const provider = sync.data.provider.find((x) => x.id === model.providerID)
|
||||
return !!provider?.models[model.modelID]
|
||||
}
|
||||
|
||||
function getFirstValidModel(
|
||||
...modelFns: (() => { providerID: string; modelID: string } | undefined)[]
|
||||
) {
|
||||
for (const modelFn of modelFns) {
|
||||
const model = modelFn()
|
||||
if (!model) continue
|
||||
if (isModelValid(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial model if provided
|
||||
onMount(() => {
|
||||
batch(() => {
|
||||
if (props.initialAgent) {
|
||||
agent.set(props.initialAgent)
|
||||
}
|
||||
if (props.initialModel) {
|
||||
const { providerID, modelID } = Provider.parseModel(props.initialModel)
|
||||
if (!providerID || !modelID)
|
||||
return toast.show({
|
||||
variant: "warning",
|
||||
message: `Invalid model format: ${props.initialModel}`,
|
||||
duration: 3000,
|
||||
})
|
||||
model.set({ providerID, modelID }, { recent: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Automatically update model when agent changes
|
||||
createEffect(() => {
|
||||
const value = agent.current()
|
||||
if (value.model) {
|
||||
if (isModelValid(value.model))
|
||||
model.set({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
else
|
||||
toast.show({
|
||||
variant: "warning",
|
||||
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const agent = iife(() => {
|
||||
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const [agentStore, setAgentStore] = createStore<{
|
||||
current: string
|
||||
}>({
|
||||
current: agents()[0].name,
|
||||
})
|
||||
const { theme } = useTheme()
|
||||
const colors = createMemo(() => [
|
||||
theme.secondary,
|
||||
theme.accent,
|
||||
theme.success,
|
||||
theme.warning,
|
||||
theme.primary,
|
||||
theme.error,
|
||||
])
|
||||
return {
|
||||
list() {
|
||||
return agents()
|
||||
},
|
||||
current() {
|
||||
return agents().find((x) => x.name === agentStore.current)!
|
||||
},
|
||||
set(name: string) {
|
||||
if (!agents().some((x) => x.name === name))
|
||||
return toast.show({
|
||||
variant: "warning",
|
||||
message: `Agent not found: ${name}`,
|
||||
duration: 3000,
|
||||
})
|
||||
setAgentStore("current", name)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
batch(() => {
|
||||
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
|
||||
if (next < 0) next = agents().length - 1
|
||||
if (next >= agents().length) next = 0
|
||||
const value = agents()[next]
|
||||
setAgentStore("current", value.name)
|
||||
})
|
||||
},
|
||||
color(name: string) {
|
||||
const index = agents().findIndex((x) => x.name === name)
|
||||
return colors()[index % colors().length]
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const model = iife(() => {
|
||||
const [modelStore, setModelStore] = createStore<{
|
||||
ready: boolean
|
||||
model: Record<
|
||||
string,
|
||||
{
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
>
|
||||
recent: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
}>({
|
||||
ready: false,
|
||||
model: {},
|
||||
recent: [],
|
||||
})
|
||||
|
||||
const file = Bun.file(path.join(Global.Path.state, "model.json"))
|
||||
|
||||
file
|
||||
.json()
|
||||
.then((x) => {
|
||||
setModelStore("recent", x.recent)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setModelStore("ready", true)
|
||||
})
|
||||
|
||||
const fallbackModel = createMemo(() => {
|
||||
if (sync.data.config.model) {
|
||||
const { providerID, modelID } = Provider.parseModel(sync.data.config.model)
|
||||
if (isModelValid({ providerID, modelID })) {
|
||||
return {
|
||||
providerID,
|
||||
modelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of modelStore.recent) {
|
||||
if (isModelValid(item)) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
const provider = sync.data.provider[0]
|
||||
const model = Object.values(provider.models)[0]
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
}
|
||||
})
|
||||
|
||||
const currentModel = createMemo(() => {
|
||||
const a = agent.current()
|
||||
return getFirstValidModel(
|
||||
() => modelStore.model[a.name],
|
||||
() => a.model,
|
||||
fallbackModel,
|
||||
)!
|
||||
})
|
||||
|
||||
return {
|
||||
current: currentModel,
|
||||
get ready() {
|
||||
return modelStore.ready
|
||||
},
|
||||
recent() {
|
||||
return modelStore.recent
|
||||
},
|
||||
parsed: createMemo(() => {
|
||||
const value = currentModel()
|
||||
const provider = sync.data.provider.find((x) => x.id === value.providerID)!
|
||||
const model = provider.models[value.modelID]
|
||||
return {
|
||||
provider: provider.name ?? value.providerID,
|
||||
model: model.name ?? value.modelID,
|
||||
}
|
||||
}),
|
||||
cycle(direction: 1 | -1) {
|
||||
const current = currentModel()
|
||||
if (!current) return
|
||||
const recent = modelStore.recent
|
||||
const index = recent.findIndex(
|
||||
(x) => x.providerID === current.providerID && x.modelID === current.modelID,
|
||||
)
|
||||
if (index === -1) return
|
||||
let next = index + direction
|
||||
if (next < 0) next = recent.length - 1
|
||||
if (next >= recent.length) next = 0
|
||||
const val = recent[next]
|
||||
if (!val) return
|
||||
setModelStore("model", agent.current().name, { ...val })
|
||||
},
|
||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
if (!isModelValid(model)) {
|
||||
toast.show({
|
||||
message: `Model ${model.providerID}/${model.modelID} is not valid`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
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()
|
||||
setModelStore("recent", uniq)
|
||||
Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
recent: modelStore.recent,
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const setInitialPrompt = createEventBus<string>()
|
||||
|
||||
onMount(() => {
|
||||
if (props.initialPrompt) setInitialPrompt.emit(props.initialPrompt)
|
||||
})
|
||||
|
||||
const result = {
|
||||
model,
|
||||
agent,
|
||||
get setInitialPrompt() {
|
||||
return setInitialPrompt
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user