Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f709e0b48b | ||
|
|
57e1bffbd5 | ||
|
|
f321661b4c | ||
|
|
7ecdc1b5d8 | ||
|
|
39917a35ce | ||
|
|
bfe3f03e03 | ||
|
|
d01af65dbc | ||
|
|
80305813f5 | ||
|
|
061877e275 | ||
|
|
05e6c3d8a0 | ||
|
|
093fbca711 | ||
|
|
81deea855f | ||
|
|
5c67bebf86 | ||
|
|
9cc1f2884f | ||
|
|
f2b547cc45 | ||
|
|
70310a37b3 | ||
|
|
eb7f4e20df | ||
|
|
ea21bfd3c6 | ||
|
|
22d5be9bf8 | ||
|
|
1c878c662b | ||
|
|
55d154d4ac | ||
|
|
f5c7a94abe | ||
|
|
7ec3900208 | ||
|
|
5d95846df1 | ||
|
|
d47feb9969 | ||
|
|
8f135d13e3 | ||
|
|
f9ab4102f6 | ||
|
|
f9117bcc7f | ||
|
|
6e712f9faf | ||
|
|
b207ed2b7b | ||
|
|
945de4eddc | ||
|
|
cd655177d9 | ||
|
|
8f90497fc4 | ||
|
|
9659efca46 | ||
|
|
d0377a95cf | ||
|
|
3b20bf6d4f | ||
|
|
c3e52580b0 | ||
|
|
2badfcdcf4 | ||
|
|
f589fc2327 | ||
|
|
d3b6545e7c | ||
|
|
3f911b22b0 | ||
|
|
5199141369 | ||
|
|
d86d3e7ea1 | ||
|
|
fe8d29cb2b | ||
|
|
edd6198999 | ||
|
|
c3b2c27997 | ||
|
|
679aeb29f0 | ||
|
|
190413580f | ||
|
|
8c9fbc7717 | ||
|
|
9d3fdda674 | ||
|
|
449994f120 | ||
|
|
d772fff776 | ||
|
|
71b43fd02e | ||
|
|
f40b91ab7a | ||
|
|
6404bd006d | ||
|
|
75157e515c | ||
|
|
5a96ee8e1b | ||
|
|
ee6ceb4c64 | ||
|
|
9d53628e19 | ||
|
|
869b476145 | ||
|
|
223d487787 | ||
|
|
5ead6d7dd5 | ||
|
|
a98454217f | ||
|
|
cbb75d8577 | ||
|
|
4ab992a9a9 | ||
|
|
80b0a93d64 | ||
|
|
e749d48534 | ||
|
|
d7e873f807 | ||
|
|
c23510346b | ||
|
|
f9c5df05a1 | ||
|
|
02b4d1e2fc | ||
|
|
0b36eb8760 | ||
|
|
36bec9948c | ||
|
|
2db73c39df | ||
|
|
6107666d04 | ||
|
|
cc2bd7141f | ||
|
|
ee442975df | ||
|
|
9b1a508657 | ||
|
|
288c977596 | ||
|
|
6b799b304c | ||
|
|
92c126d875 | ||
|
|
7123fbeb47 | ||
|
|
84bb692193 | ||
|
|
079095d7a9 | ||
|
|
28e1d67ea4 |
@@ -0,0 +1,50 @@
|
||||
name: snapshot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- opentui
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ">=1.24.0"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.21
|
||||
|
||||
- name: Cache ~/.bun
|
||||
id: cache-bun
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.bun
|
||||
key: ${{ runner.os }}-bun-1-2-21-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-1-2-21-
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
./packages/opencode/script/publish.ts
|
||||
env:
|
||||
OPENCODE_SNAPSHOT: true
|
||||
OPENCODE_TAG: ${{ github.ref_name }}
|
||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
bun prettier --write --ignore-unknown $(git diff --name-only HEAD~1 HEAD)
|
||||
bun run typecheck
|
||||
@@ -1,11 +0,0 @@
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
export default tool({
|
||||
description: "call this tool when you want to give up",
|
||||
args: {
|
||||
message: tool.schema.string().describe("give up message"),
|
||||
},
|
||||
async execute(args) {
|
||||
return "Hey fuck you!"
|
||||
},
|
||||
})
|
||||
@@ -85,3 +85,9 @@
|
||||
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
|
||||
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
|
||||
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
|
||||
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
|
||||
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
|
||||
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
|
||||
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
|
||||
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
|
||||
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
|
||||
|
||||
+2
-5
@@ -1,4 +1,3 @@
|
||||
import { WebhookEndpoint } from "pulumi-stripe"
|
||||
import { domain } from "./stage"
|
||||
|
||||
////////////////
|
||||
@@ -68,13 +67,14 @@ export const auth = new sst.cloudflare.Worker("AuthApi", {
|
||||
// GATEWAY
|
||||
////////////////
|
||||
|
||||
export const stripeWebhook = new WebhookEndpoint("StripeWebhookEndpoint", {
|
||||
export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", {
|
||||
url: $interpolate`https://${domain}/stripe/webhook`,
|
||||
enabledEvents: [
|
||||
"checkout.session.async_payment_failed",
|
||||
"checkout.session.async_payment_succeeded",
|
||||
"checkout.session.completed",
|
||||
"checkout.session.expired",
|
||||
"charge.refunded",
|
||||
"customer.created",
|
||||
"customer.deleted",
|
||||
"customer.updated",
|
||||
@@ -93,9 +93,6 @@ export const stripeWebhook = new WebhookEndpoint("StripeWebhookEndpoint", {
|
||||
"customer.subscription.resumed",
|
||||
"customer.subscription.trial_will_end",
|
||||
"customer.subscription.updated",
|
||||
"customer.tax_id.created",
|
||||
"customer.tax_id.deleted",
|
||||
"customer.tax_id.updated",
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ mkdir -p "$INSTALL_DIR"
|
||||
|
||||
if [ -z "$requested_version" ]; then
|
||||
url="https://github.com/sst/opencode/releases/latest/download/$filename"
|
||||
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
|
||||
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
|
||||
|
||||
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
||||
echo -e "${RED}Failed to fetch version information${NC}"
|
||||
|
||||
+2
-7
@@ -7,8 +7,7 @@
|
||||
"scripts": {
|
||||
"dev": "bun run --conditions=development packages/opencode/src/index.ts",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"generate": "(cd packages/sdk && ./js/script/generate.ts) && (cd packages/sdk/stainless && ./generate.ts)",
|
||||
"postinstall": "./script/hooks"
|
||||
"prepare": "husky"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
@@ -19,8 +18,6 @@
|
||||
"catalog": {
|
||||
"@types/bun": "1.2.21",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@types/node": "22.13.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"ai": "5.0.8",
|
||||
@@ -33,10 +30,8 @@
|
||||
"solid-js": "1.9.9"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"pulumi-stripe": "0.0.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "9.1.7",
|
||||
"prettier": "3.6.2",
|
||||
"sst": "3.17.13",
|
||||
"turbo": "2.5.6"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
src/assets/theme.css
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "0.10.5",
|
||||
"version": "0.12.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -24,6 +24,7 @@
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solidjs/router": "0.15.3",
|
||||
|
||||
@@ -37,7 +37,7 @@ class ColorResolver {
|
||||
if (typeof value === "string") {
|
||||
if (value === "none") return { dark: value, light: value }
|
||||
if (value.startsWith("#")) {
|
||||
return { dark: value.toUpperCase(), light: value.toUpperCase() }
|
||||
return { dark: value.toLowerCase(), light: value.toLowerCase() }
|
||||
}
|
||||
const resolved = this.resolveReference(value)
|
||||
return { dark: resolved, light: resolved }
|
||||
@@ -57,7 +57,7 @@ class ColorResolver {
|
||||
if (typeof value === "string") {
|
||||
if (value === "none") return value
|
||||
if (value.startsWith("#")) {
|
||||
return value.toUpperCase()
|
||||
return value.toLowerCase()
|
||||
}
|
||||
return this.resolveReference(value)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ class ColorResolver {
|
||||
if (typeof colorValue === "string") {
|
||||
if (colorValue === "none") return colorValue
|
||||
if (colorValue.startsWith("#")) {
|
||||
return colorValue.toUpperCase()
|
||||
return colorValue.toLowerCase()
|
||||
}
|
||||
return this.resolveReference(colorValue)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ export default function FileTree(props: {
|
||||
<Dynamic
|
||||
component={p.as ?? "div"}
|
||||
classList={{
|
||||
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-panel cursor-pointer": true,
|
||||
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element cursor-pointer": true,
|
||||
"bg-background-element": local.file.active()?.path === p.node.path,
|
||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||
}}
|
||||
|
||||
@@ -1,595 +1,17 @@
|
||||
import { transformerNotationDiff } from "@shikijs/transformers"
|
||||
import { marked } from "marked"
|
||||
import markedShiki from "marked-shiki"
|
||||
import { codeToHtml } from "shiki"
|
||||
import { useMarked } from "@/context"
|
||||
import { createResource } from "solid-js"
|
||||
|
||||
const markedWithShiki = marked.use(
|
||||
markedShiki({
|
||||
highlight(code, lang) {
|
||||
return codeToHtml(code, {
|
||||
// structure: "inline",
|
||||
lang: lang || "text",
|
||||
tabindex: false,
|
||||
theme: {
|
||||
colors: {
|
||||
"actionBar.toggledBackground": "var(--theme-background-element)",
|
||||
"activityBarBadge.background": "var(--theme-accent)",
|
||||
"checkbox.border": "var(--theme-border)",
|
||||
"editor.background": "transparent",
|
||||
"editor.foreground": "var(--theme-text)",
|
||||
"editor.inactiveSelectionBackground": "var(--theme-background-element)",
|
||||
"editor.selectionHighlightBackground": "var(--theme-border-active)",
|
||||
"editorIndentGuide.activeBackground1": "var(--theme-border-subtle)",
|
||||
"editorIndentGuide.background1": "var(--theme-border-subtle)",
|
||||
"input.placeholderForeground": "var(--theme-text-muted)",
|
||||
"list.activeSelectionIconForeground": "var(--theme-text)",
|
||||
"list.dropBackground": "var(--theme-background-element)",
|
||||
"menu.background": "var(--theme-background-panel)",
|
||||
"menu.border": "var(--theme-border)",
|
||||
"menu.foreground": "var(--theme-text)",
|
||||
"menu.selectionBackground": "var(--theme-primary)",
|
||||
"menu.separatorBackground": "var(--theme-border)",
|
||||
"ports.iconRunningProcessForeground": "var(--theme-success)",
|
||||
"sideBarSectionHeader.background": "transparent",
|
||||
"sideBarSectionHeader.border": "var(--theme-border-subtle)",
|
||||
"sideBarTitle.foreground": "var(--theme-text-muted)",
|
||||
"statusBarItem.remoteBackground": "var(--theme-success)",
|
||||
"statusBarItem.remoteForeground": "var(--theme-text)",
|
||||
"tab.lastPinnedBorder": "var(--theme-border-subtle)",
|
||||
"tab.selectedBackground": "var(--theme-background-element)",
|
||||
"tab.selectedForeground": "var(--theme-text-muted)",
|
||||
"terminal.inactiveSelectionBackground": "var(--theme-background-element)",
|
||||
"widget.border": "var(--theme-border)",
|
||||
},
|
||||
displayName: "opencode",
|
||||
name: "opencode",
|
||||
semanticHighlighting: true,
|
||||
semanticTokenColors: {
|
||||
customLiteral: "var(--theme-syntax-function)",
|
||||
newOperator: "var(--theme-syntax-operator)",
|
||||
numberLiteral: "var(--theme-syntax-number)",
|
||||
stringLiteral: "var(--theme-syntax-string)",
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: [
|
||||
"meta.embedded",
|
||||
"source.groovy.embedded",
|
||||
"string meta.image.inline.markdown",
|
||||
"variable.legacy.builtin.python",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "emphasis",
|
||||
settings: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "strong",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "header",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-heading)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "comment",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-comment)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.language",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"variable.other.enummember",
|
||||
"keyword.operator.plus.exponent",
|
||||
"keyword.operator.minus.exponent",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.name.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.tag.css", "entity.name.tag.less"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.other.attribute-name",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"entity.other.attribute-name.class.css",
|
||||
"source.css entity.other.attribute-name.class",
|
||||
"entity.other.attribute-name.id.css",
|
||||
"entity.other.attribute-name.parent-selector.css",
|
||||
"entity.other.attribute-name.parent.less",
|
||||
"source.css entity.other.attribute-name.pseudo-class",
|
||||
"entity.other.attribute-name.pseudo-element.css",
|
||||
"source.css.less entity.other.attribute-name.id",
|
||||
"entity.other.attribute-name.scss",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "invalid",
|
||||
settings: {
|
||||
foreground: "var(--theme-error)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.underline",
|
||||
settings: {
|
||||
fontStyle: "underline",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.bold",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
foreground: "var(--theme-markdown-strong)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.heading",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
foreground: "var(--theme-markdown-heading)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.italic",
|
||||
settings: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.strikethrough",
|
||||
settings: {
|
||||
fontStyle: "strikethrough",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.inserted",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-added)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.deleted",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-removed)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.changed",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-context)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.quote.begin.markdown",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-block-quote)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.list.begin.markdown",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-list-enumeration)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.inline.raw",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-code)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-punctuation)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.preprocessor", "entity.name.function.preprocessor"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.preprocessor.string",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.preprocessor.numeric",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.structure.dictionary.key.python",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.diff.header",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-hunk-header)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "storage",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "storage.type",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["storage.modifier", "keyword.operator.noexcept"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["string", "meta.embedded.assembly"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.value",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.template-expression.begin",
|
||||
"punctuation.definition.template-expression.end",
|
||||
"punctuation.section.embedded",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.template.expression"],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.type.vendored.property-name",
|
||||
"support.type.property-name",
|
||||
"source.css variable",
|
||||
"source.coffee.embedded",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.control",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.operator",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"keyword.operator.new",
|
||||
"keyword.operator.expression",
|
||||
"keyword.operator.cast",
|
||||
"keyword.operator.sizeof",
|
||||
"keyword.operator.alignof",
|
||||
"keyword.operator.typeid",
|
||||
"keyword.operator.alignas",
|
||||
"keyword.operator.instanceof",
|
||||
"keyword.operator.logical.python",
|
||||
"keyword.operator.wordlike",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.other.unit",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "support.function.git-rebase",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.sha.git-rebase",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"storage.modifier.import.java",
|
||||
"variable.language.wildcard.java",
|
||||
"storage.modifier.package.java",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "variable.language",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"entity.name.function",
|
||||
"support.function",
|
||||
"support.constant.handlebars",
|
||||
"source.powershell variable.other.member",
|
||||
"entity.name.operator.custom-literal",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-function)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.class",
|
||||
"support.type",
|
||||
"entity.name.type",
|
||||
"entity.name.namespace",
|
||||
"entity.other.attribute",
|
||||
"entity.name.scope-resolution",
|
||||
"entity.name.class",
|
||||
"storage.type.numeric.go",
|
||||
"storage.type.byte.go",
|
||||
"storage.type.boolean.go",
|
||||
"storage.type.string.go",
|
||||
"storage.type.uintptr.go",
|
||||
"storage.type.error.go",
|
||||
"storage.type.rune.go",
|
||||
"storage.type.cs",
|
||||
"storage.type.generic.cs",
|
||||
"storage.type.modifier.cs",
|
||||
"storage.type.variable.cs",
|
||||
"storage.type.annotation.java",
|
||||
"storage.type.generic.java",
|
||||
"storage.type.java",
|
||||
"storage.type.object.array.java",
|
||||
"storage.type.primitive.array.java",
|
||||
"storage.type.primitive.java",
|
||||
"storage.type.token.java",
|
||||
"storage.type.groovy",
|
||||
"storage.type.annotation.groovy",
|
||||
"storage.type.parameters.groovy",
|
||||
"storage.type.generic.groovy",
|
||||
"storage.type.object.array.groovy",
|
||||
"storage.type.primitive.array.groovy",
|
||||
"storage.type.primitive.groovy",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-type)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"meta.type.cast.expr",
|
||||
"meta.type.new.expr",
|
||||
"support.constant.math",
|
||||
"support.constant.dom",
|
||||
"support.constant.json",
|
||||
"entity.other.inherited-class",
|
||||
"punctuation.separator.namespace.ruby",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-type)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"keyword.control",
|
||||
"source.cpp keyword.operator.new",
|
||||
"keyword.operator.delete",
|
||||
"keyword.other.using",
|
||||
"keyword.other.directive.using",
|
||||
"keyword.other.operator",
|
||||
"entity.name.operator",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"variable",
|
||||
"meta.definition.variable.name",
|
||||
"support.variable",
|
||||
"entity.name.variable",
|
||||
"constant.other.placeholder",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["variable.other.constant", "variable.other.enummember"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.object-literal.key"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.constant.property-value",
|
||||
"support.constant.font-name",
|
||||
"support.constant.media-type",
|
||||
"support.constant.media",
|
||||
"constant.other.color.rgb-value",
|
||||
"constant.other.rgb-value",
|
||||
"support.constant.color",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.group.regexp",
|
||||
"punctuation.definition.group.assertion.regexp",
|
||||
"punctuation.definition.character-class.regexp",
|
||||
"punctuation.character.set.begin.regexp",
|
||||
"punctuation.character.set.end.regexp",
|
||||
"keyword.operator.negation.regexp",
|
||||
"support.other.parenthesis.regexp",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.character.character-class.regexp",
|
||||
"constant.other.character-class.set.regexp",
|
||||
"constant.other.character-class.regexp",
|
||||
"constant.character.set.regexp",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.operator.quantifier.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["constant.character", "constant.other.option"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.character.escape",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.name.label",
|
||||
settings: {
|
||||
foreground: "var(--theme-text-muted)",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "dark",
|
||||
},
|
||||
transformers: [transformerNotationDiff()],
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
function strip(text: string): string {
|
||||
const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
|
||||
const match = text.match(wrappedRe)
|
||||
return match ? match[2] : text
|
||||
}
|
||||
|
||||
export default function Markdown(props: { text: string; class?: string }) {
|
||||
export function Markdown(props: { text: string; class?: string }) {
|
||||
const marked = useMarked()
|
||||
const [html] = createResource(
|
||||
() => strip(props.text),
|
||||
async (markdown) => {
|
||||
return markedWithShiki.parse(markdown)
|
||||
return marked.parse(markdown)
|
||||
},
|
||||
)
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Icon, IconButton } from "@/ui"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { entries, flatMap, groupBy, map, pipe } from "remeda"
|
||||
import { createList } from "solid-list"
|
||||
import fuzzysort from "fuzzysort"
|
||||
|
||||
interface SelectDialogProps<T> {
|
||||
items: T[] | ((filter: string) => Promise<T[]>)
|
||||
key: (item: T) => string
|
||||
render: (item: T) => JSX.Element
|
||||
filter?: string[]
|
||||
current?: T
|
||||
placeholder?: string
|
||||
groupBy?: (x: T) => string
|
||||
onSelect?: (value: T | undefined) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function SelectDialog<T>(props: SelectDialogProps<T>) {
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const [store, setStore] = createStore({
|
||||
filter: "",
|
||||
mouseActive: false,
|
||||
})
|
||||
|
||||
const [grouped] = createResource(
|
||||
() => store.filter,
|
||||
async (filter) => {
|
||||
const needle = filter.toLowerCase()
|
||||
const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
|
||||
const result = pipe(
|
||||
all,
|
||||
(x) => {
|
||||
if (!needle) return x
|
||||
if (!props.filter && Array.isArray(x) && x.every((e) => typeof e === "string")) {
|
||||
return fuzzysort.go(needle, x).map((x) => x.target) as T[]
|
||||
}
|
||||
return fuzzysort.go(needle, x, { keys: props.filter! }).map((x) => x.obj)
|
||||
},
|
||||
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
|
||||
// mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))),
|
||||
entries(),
|
||||
map(([k, v]) => ({ category: k, items: v })),
|
||||
)
|
||||
return result
|
||||
},
|
||||
)
|
||||
const flat = createMemo(() => {
|
||||
return pipe(
|
||||
grouped() || [],
|
||||
flatMap((x) => x.items),
|
||||
)
|
||||
})
|
||||
const list = createList({
|
||||
items: () => flat().map(props.key),
|
||||
initialActive: props.current ? props.key(props.current) : undefined,
|
||||
loop: true,
|
||||
})
|
||||
const resetSelection = () => {
|
||||
const all = flat()
|
||||
if (all.length === 0) return
|
||||
list.setActive(props.key(all[0]))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
store.filter
|
||||
scrollRef?.scrollTo(0, 0)
|
||||
resetSelection()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const all = flat()
|
||||
if (store.mouseActive || all.length === 0) return
|
||||
if (list.active() === props.key(all[0])) {
|
||||
scrollRef?.scrollTo(0, 0)
|
||||
return
|
||||
}
|
||||
const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`)
|
||||
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
})
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
setStore("filter", value)
|
||||
resetSelection()
|
||||
}
|
||||
|
||||
const handleSelect = (item: T) => {
|
||||
props.onSelect?.(item)
|
||||
props.onClose?.()
|
||||
}
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
setStore("mouseActive", false)
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const selected = flat().find((x) => props.key(x) === list.active())
|
||||
if (selected) handleSelect(selected)
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
props.onClose?.()
|
||||
} else {
|
||||
list.onKeyDown(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" />
|
||||
<Dialog.Content
|
||||
class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl
|
||||
shadow-[0_0_33px_rgba(0,0,0,0.8)]
|
||||
bg-background border border-border-subtle/30 rounded-lg z-[101]
|
||||
max-h-[60vh] flex flex-col"
|
||||
>
|
||||
<div class="border-b border-border-subtle/30">
|
||||
<div class="relative">
|
||||
<Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" />
|
||||
<input
|
||||
type="text"
|
||||
value={store.filter}
|
||||
onInput={(e) => handleInput(e.currentTarget.value)}
|
||||
onKeyDown={handleKey}
|
||||
placeholder={props.placeholder}
|
||||
class="w-full pl-10 pr-4 py-2 rounded-t-md
|
||||
text-sm text-text placeholder-text-muted/70
|
||||
focus:outline-none"
|
||||
autofocus
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
{/* <Show when={fileResults.loading && mode() === "files"}>
|
||||
<div class="text-text-muted">
|
||||
<Icon name="refresh" size={14} class="animate-spin" />
|
||||
</div>
|
||||
</Show> */}
|
||||
<Show when={store.filter}>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="text-text-muted hover:text-text"
|
||||
onClick={() => {
|
||||
setStore("filter", "")
|
||||
resetSelection()
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto">
|
||||
<Show
|
||||
when={flat().length > 0}
|
||||
fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>}
|
||||
>
|
||||
<For each={grouped()}>
|
||||
{(group) => (
|
||||
<>
|
||||
<Show when={group.category}>
|
||||
<div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase">
|
||||
{group.category}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="p-2">
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
data-key={props.key(item)}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseMove={() => {
|
||||
setStore("mouseActive", true)
|
||||
list.setActive(props.key(item))
|
||||
}}
|
||||
classList={{
|
||||
"w-full px-3 py-2 flex items-center gap-3": true,
|
||||
"rounded-md text-left transition-colors group": true,
|
||||
"bg-background-element": props.key(item) === list.active(),
|
||||
}}
|
||||
>
|
||||
{props.render(item)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted">
|
||||
<div class="flex items-center gap-5">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
|
||||
↑↓
|
||||
</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
|
||||
↵
|
||||
</kbd>
|
||||
Select
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
|
||||
ESC
|
||||
</kbd>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
<span>{`${flat().length} results`}</span>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +1,26 @@
|
||||
import { Select as KobalteSelect } from "@kobalte/core/select"
|
||||
import { createEffect, createMemo, Show } from "solid-js"
|
||||
import { createMemo } from "solid-js"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { Icon } from "@/ui/icon"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { pipe, groupBy, entries, map } from "remeda"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button, type ButtonProps } from "@/ui"
|
||||
|
||||
export interface SelectProps<T> {
|
||||
variant?: "default" | "outline"
|
||||
size?: "sm" | "md" | "lg"
|
||||
placeholder?: string
|
||||
filter?:
|
||||
| false
|
||||
| {
|
||||
placeholder?: string
|
||||
keys: string[]
|
||||
}
|
||||
options: T[]
|
||||
current?: T
|
||||
value?: (x: T) => string
|
||||
label?: (x: T) => string
|
||||
groupBy?: (x: T) => string
|
||||
onFilter?: (query: string) => void
|
||||
onSelect?: (value: T | undefined) => void
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
}
|
||||
|
||||
export function Select<T>(props: SelectProps<T>) {
|
||||
let inputRef: HTMLInputElement | undefined = undefined
|
||||
let listboxRef: HTMLUListElement | undefined = undefined
|
||||
const [store, setStore] = createStore({
|
||||
filter: "",
|
||||
})
|
||||
export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
const grouped = createMemo(() => {
|
||||
const needle = store.filter.toLowerCase()
|
||||
const result = pipe(
|
||||
props.options,
|
||||
(x) =>
|
||||
!needle || !props.filter
|
||||
? x
|
||||
: fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj),
|
||||
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
|
||||
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
|
||||
entries(),
|
||||
@@ -48,19 +28,6 @@ export function Select<T>(props: SelectProps<T>) {
|
||||
)
|
||||
return result
|
||||
})
|
||||
// const flat = createMemo(() => {
|
||||
// return pipe(
|
||||
// grouped(),
|
||||
// flatMap(({ options }) => options),
|
||||
// )
|
||||
// })
|
||||
|
||||
createEffect(() => {
|
||||
store.filter
|
||||
listboxRef?.scrollTo(0, 0)
|
||||
// setStore("selected", 0)
|
||||
// scroll.scrollTo(0)
|
||||
})
|
||||
|
||||
return (
|
||||
<KobalteSelect<T, { category: string; options: T[] }>
|
||||
@@ -89,36 +56,21 @@ export function Select<T>(props: SelectProps<T>) {
|
||||
<KobalteSelect.ItemLabel>
|
||||
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
|
||||
</KobalteSelect.ItemLabel>
|
||||
<KobalteSelect.ItemIndicator
|
||||
classList={{
|
||||
"ml-auto": true,
|
||||
}}
|
||||
>
|
||||
<KobalteSelect.ItemIndicator class="ml-auto">
|
||||
<Icon name="checkmark" size={16} />
|
||||
</KobalteSelect.ItemIndicator>
|
||||
</KobalteSelect.Item>
|
||||
)}
|
||||
onChange={(v) => {
|
||||
if (props.onSelect) props.onSelect(v ?? undefined)
|
||||
if (v !== null) {
|
||||
// close the select
|
||||
}
|
||||
props.onSelect?.(v ?? undefined)
|
||||
}}
|
||||
onOpenChange={(v) => v || setStore("filter", "")}
|
||||
>
|
||||
<KobalteSelect.Trigger
|
||||
as={Button}
|
||||
size={props.size || "sm"}
|
||||
variant={props.variant || "secondary"}
|
||||
classList={{
|
||||
...(props.classList ?? {}),
|
||||
"flex w-full items-center justify-between rounded-md transition-colors": true,
|
||||
"focus-visible:outline-none focus-visible:ring focus-visible:ring-border-active/30": true,
|
||||
"disabled:cursor-not-allowed disabled:opacity-50": true,
|
||||
"data-[placeholder-shown]:text-text-muted cursor-pointer": true,
|
||||
"hover:bg-background-element focus-visible:ring-border-active": true,
|
||||
"bg-background-element text-text": props.variant === "default" || !props.variant,
|
||||
"border-2 border-border bg-transparent text-text": props.variant === "outline",
|
||||
"h-6 pl-2 text-xs": props.size === "sm",
|
||||
"h-8 pl-3 text-sm": props.size === "md" || !props.size,
|
||||
"h-10 pl-4 text-base": props.size === "lg",
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
@@ -140,13 +92,6 @@ export function Select<T>(props: SelectProps<T>) {
|
||||
</KobalteSelect.Trigger>
|
||||
<KobalteSelect.Portal>
|
||||
<KobalteSelect.Content
|
||||
onKeyDown={(e) => {
|
||||
if (!props.filter) return
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") {
|
||||
return
|
||||
}
|
||||
inputRef?.focus()
|
||||
}}
|
||||
classList={{
|
||||
"min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true,
|
||||
"bg-background-panel p-1 shadow-md z-50": true,
|
||||
@@ -154,33 +99,7 @@ export function Select<T>(props: SelectProps<T>) {
|
||||
"data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true,
|
||||
}}
|
||||
>
|
||||
<Show when={props.filter}>
|
||||
<input
|
||||
ref={(el) => (inputRef = el)}
|
||||
id="select-filter"
|
||||
type="text"
|
||||
placeholder={props.filter ? props.filter.placeholder : "Filter items"}
|
||||
value={store.filter}
|
||||
onInput={(e) => setStore("filter", e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
listboxRef?.focus()
|
||||
}
|
||||
}}
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"px-2 pb-2 text-text font-light placeholder-text-muted/70 text-xs focus:outline-none": true,
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<KobalteSelect.Listbox
|
||||
ref={(el) => (listboxRef = el)}
|
||||
classList={{
|
||||
"overflow-y-auto max-h-48 no-scrollbar": true,
|
||||
}}
|
||||
/>
|
||||
<KobalteSelect.Listbox class="overflow-y-auto max-h-48" />
|
||||
</KobalteSelect.Content>
|
||||
</KobalteSelect.Portal>
|
||||
</KobalteSelect>
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function SessionList() {
|
||||
const local = useLocal()
|
||||
|
||||
return (
|
||||
<VList data={sync.data.session} class="p-2 no-scrollbar">
|
||||
<VList data={sync.data.session} class="p-2">
|
||||
{(session) => (
|
||||
<Tooltip placement="right" value={session.title} class="w-full min-w-0">
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLocal, useSync } from "@/context"
|
||||
import { Collapsible, Icon, type IconProps } from "@/ui"
|
||||
import { Collapsible, Icon } from "@/ui"
|
||||
import type { Part, ToolPart } from "@opencode-ai/sdk"
|
||||
import { DateTime } from "luxon"
|
||||
import {
|
||||
@@ -13,58 +13,14 @@ import {
|
||||
type ParentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { getFilename } from "@/utils"
|
||||
import Markdown from "./markdown"
|
||||
import { Markdown } from "./markdown"
|
||||
import { Code } from "./code"
|
||||
import { createElementSize } from "@solid-primitives/resize-observer"
|
||||
import { createScrollPosition } from "@solid-primitives/scroll"
|
||||
|
||||
function TimelineIcon(props: { name: IconProps["name"]; class?: string }) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"relative flex flex-none self-start items-center justify-center bg-background h-6 w-6": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<Icon name={props.name} class="text-text/40" size={18} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleTimelineIcon(props: { name: IconProps["name"]; class?: string }) {
|
||||
return (
|
||||
<>
|
||||
<TimelineIcon
|
||||
name={props.name}
|
||||
class={`group-hover/li:hidden group-has-[[data-expanded]]/li:hidden ${props.class ?? ""}`}
|
||||
/>
|
||||
<TimelineIcon
|
||||
name="chevron-right"
|
||||
class={`hidden group-hover/li:flex group-has-[[data-expanded]]/li:hidden ${props.class ?? ""}`}
|
||||
/>
|
||||
<TimelineIcon name="chevron-down" class={`hidden group-has-[[data-expanded]]/li:flex ${props.class ?? ""}`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolIcon(props: { part: ToolPart }) {
|
||||
return (
|
||||
<Switch fallback={<TimelineIcon name="hammer" />}>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<TimelineIcon name="file" />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<CollapsibleTimelineIcon name="pencil" />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<CollapsibleTimelineIcon name="file-plus" />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function Part(props: ParentProps & ComponentProps<"div">) {
|
||||
const [local, others] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
@@ -97,9 +53,13 @@ function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps
|
||||
}
|
||||
|
||||
function ReadToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
const local = useLocal()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Reading file...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => {
|
||||
const path = state().input["filePath"] as string
|
||||
@@ -110,13 +70,27 @@ function ReadToolPart(props: { part: ToolPart }) {
|
||||
)
|
||||
}}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<div>
|
||||
<Part>
|
||||
<span class="text-text-muted">Read</span> {getFilename(state().input["filePath"] as string)}
|
||||
</Part>
|
||||
<div class="text-error">{sync.sanitize(state().error)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function EditToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Preparing edit...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
@@ -135,13 +109,30 @@ function EditToolPart(props: { part: ToolPart }) {
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="text-error">{sync.sanitize(state().error)}</div>
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function WriteToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Preparing write...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
@@ -155,35 +146,95 @@ function WriteToolPart(props: { part: ToolPart }) {
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<div>
|
||||
<Part>
|
||||
<span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)}
|
||||
</Part>
|
||||
<div class="text-error">{sync.sanitize(state().error)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function BashToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Writing shell command...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
defaultOpen
|
||||
title={
|
||||
<>
|
||||
<span class="text-text-muted">Run command:</span> {state().input["command"]}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} />
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="text-text-muted">Shell</span> {state().input["command"]}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="text-error">{sync.sanitize(state().error)}</div>
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolPart(props: { part: ToolPart }) {
|
||||
// read
|
||||
// edit
|
||||
// write
|
||||
// bash
|
||||
// ls
|
||||
// glob
|
||||
// grep
|
||||
// todowrite
|
||||
// todoread
|
||||
// webfetch
|
||||
// websearch
|
||||
// patch
|
||||
// task
|
||||
return (
|
||||
<Switch
|
||||
fallback={
|
||||
<div class="flex-auto min-w-0 text-xs">
|
||||
{props.part.type}:{props.part.tool}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<div class="min-w-0 flex-auto">
|
||||
<div class="min-w-0 flex-auto text-xs">
|
||||
<Switch
|
||||
fallback={
|
||||
<span>
|
||||
{props.part.type}:{props.part.tool}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<ReadToolPart part={props.part} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<div class="min-w-0 flex-auto">
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<EditToolPart part={props.part} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<div class="min-w-0 flex-auto">
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<WriteToolPart part={props.part} />
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
<Match when={props.part.tool === "bash"}>
|
||||
<BashToolPart part={props.part} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -196,6 +247,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
const scroll = createScrollPosition(scrollElement)
|
||||
|
||||
onMount(() => sync.session.sync(props.session))
|
||||
const session = createMemo(() => sync.session.get(props.session))
|
||||
const messages = createMemo(() => sync.data.message[props.session] ?? [])
|
||||
const working = createMemo(() => {
|
||||
const last = messages()[messages().length - 1]
|
||||
@@ -285,60 +337,33 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
<div
|
||||
ref={setRoot}
|
||||
classList={{
|
||||
"p-4 select-text flex flex-col gap-y-8": true,
|
||||
"p-4 select-text flex flex-col gap-y-1": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<ul role="list" class="space-y-2">
|
||||
<ul role="list" class="flex flex-col gap-1">
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<For each={sync.data.part[message.id]?.filter(valid)}>
|
||||
{(part) => (
|
||||
<li classList={{ "relative group/li flex gap-x-4 min-w-0 w-full": true }}>
|
||||
<div
|
||||
classList={{
|
||||
"absolute top-0 left-0 flex w-6 justify-center": true,
|
||||
"last:h-10 not-last:-bottom-10": true,
|
||||
}}
|
||||
>
|
||||
<div class="w-px bg-border-subtle" />
|
||||
</div>
|
||||
<Switch
|
||||
fallback={
|
||||
<div class="m-0.5 relative flex size-5 flex-none items-center justify-center bg-background">
|
||||
<div class="size-1 rounded-full bg-text/10 ring ring-text/20" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Match when={part.type === "text"}>
|
||||
<Switch>
|
||||
<Match when={message.role === "user"}>
|
||||
<TimelineIcon name="avatar-square" />
|
||||
</Match>
|
||||
<Match when={message.role === "assistant"}>
|
||||
<TimelineIcon name="sparkles" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning"}>
|
||||
<CollapsibleTimelineIcon name="brain" />
|
||||
</Match>
|
||||
<Match when={part.type === "tool" && part}>{(part) => <ToolIcon part={part()} />}</Match>
|
||||
</Switch>
|
||||
<li class="group/li">
|
||||
<Switch fallback={<div class="flex-auto min-w-0 text-xs mt-1 text-left">{part.type}</div>}>
|
||||
<Match when={part.type === "text" && part}>
|
||||
{(part) => (
|
||||
<Switch>
|
||||
<Match when={message.role === "user"}>
|
||||
<div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0">
|
||||
<div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0 mt-5 group-first/li:mt-0">
|
||||
<p class="w-full rounded-md p-3 ring-1 ring-text/15 ring-inset text-xs bg-background-panel">
|
||||
<span class="font-medium text-text whitespace-pre-wrap break-words">{part().text}</span>
|
||||
</p>
|
||||
<p class="text-xs text-text-muted">12:07pm · adam</p>
|
||||
<p class="text-xs text-text-muted">
|
||||
{DateTime.fromMillis(message.time.created).toRelative()} ·{" "}
|
||||
{sync.data.config.username ?? "user"}
|
||||
</p>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={message.role === "assistant"}>
|
||||
<Markdown text={part().text} class="text-text" />
|
||||
<Markdown text={sync.sanitize(part().text)} class="text-text mt-1" />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
@@ -347,9 +372,11 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
{(part) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="text-text-muted">Thought</span> for {duration(part())}s
|
||||
</>
|
||||
<Switch fallback={<span class="text-text-muted">Thinking</span>}>
|
||||
<Match when={part().time.end}>
|
||||
<span class="text-text-muted">Thought</span> for {duration(part())}s
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<Markdown text={part().text} />
|
||||
@@ -361,9 +388,84 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<Show when={false}>
|
||||
<Collapsible defaultOpen={false}>
|
||||
<Collapsible.Trigger>
|
||||
<div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
|
||||
<Icon name="file-code" size={16} />
|
||||
<span>Raw Session Data</span>
|
||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="mt-5">
|
||||
<ul role="list" class="space-y-2">
|
||||
<li>
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" size={16} />
|
||||
<span>session</span>
|
||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code path="session.json" code={JSON.stringify(session(), null, 2)} class="[&_code]:pb-0!" />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<>
|
||||
<li>
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" size={16} />
|
||||
<span>{message.role === "user" ? "user" : "assistant"}</span>
|
||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code
|
||||
path={message.id + ".json"}
|
||||
code={JSON.stringify(message, null, 2)}
|
||||
class="[&_code]:pb-0!"
|
||||
/>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
<For each={sync.data.part[message.id]?.filter(valid)}>
|
||||
{(part) => (
|
||||
<li>
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" size={16} />
|
||||
<span>{part.type}</span>
|
||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code
|
||||
path={message.id + "." + part.id + ".json"}
|
||||
code={JSON.stringify(part, null, 2)}
|
||||
class="[&_code]:pb-0!"
|
||||
/>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { createEventBus } from "@solid-primitives/event-bus"
|
||||
import type { Event as SDKEvent } from "@opencode-ai/sdk"
|
||||
import { useSDK } from "@/context"
|
||||
|
||||
export type Event = SDKEvent // can extend with custom events later
|
||||
|
||||
function init() {
|
||||
const sdk = useSDK()
|
||||
const bus = createEventBus<Event>()
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
bus.emit(event)
|
||||
}
|
||||
})
|
||||
return bus
|
||||
}
|
||||
|
||||
type EventContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<EventContext>()
|
||||
|
||||
export function EventProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useEvent() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useEvent must be used within a EventProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export { EventProvider, useEvent } from "./event"
|
||||
export { LocalProvider, useLocal } from "./local"
|
||||
export { MarkedProvider, useMarked } from "./marked"
|
||||
export { SDKProvider, useSDK } from "./sdk"
|
||||
export { ShikiProvider, useShiki } from "./shiki"
|
||||
export { SyncProvider, useSync } from "./sync"
|
||||
export { ThemeProvider, useTheme } from "./theme"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
|
||||
import { useSync } from "./sync"
|
||||
import { uniqueBy } from "remeda"
|
||||
import type { FileContent, FileNode } from "@opencode-ai/sdk"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
|
||||
import { useSDK, useEvent, useSync } from "@/context"
|
||||
|
||||
export type LocalFile = FileNode &
|
||||
Partial<{
|
||||
@@ -16,16 +15,22 @@ export type LocalFile = FileNode &
|
||||
view: "raw" | "diff-unified" | "diff-split"
|
||||
folded: string[]
|
||||
selectedChange: number
|
||||
status: FileStatus
|
||||
}>
|
||||
export type TextSelection = LocalFile["selection"]
|
||||
export type View = LocalFile["view"]
|
||||
|
||||
export type LocalModel = Omit<Model, "provider"> & {
|
||||
provider: Provider
|
||||
}
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
function init() {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const [store, setStore] = createStore<{
|
||||
current: string
|
||||
}>({
|
||||
@@ -55,18 +60,14 @@ function init() {
|
||||
})()
|
||||
|
||||
const model = (() => {
|
||||
const list = createMemo(() =>
|
||||
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
|
||||
)
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
model: Record<
|
||||
string,
|
||||
{
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
>
|
||||
recent: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
model: Record<string, ModelKey>
|
||||
recent: ModelKey[]
|
||||
}>({
|
||||
model: {},
|
||||
recent: [],
|
||||
@@ -82,37 +83,21 @@ function init() {
|
||||
if (store.recent.length) return store.recent[0]
|
||||
const provider = sync.data.provider[0]
|
||||
const model = Object.values(provider.models)[0]
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
}
|
||||
return { modelID: model.id, providerID: provider.id }
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
const a = agent.current()
|
||||
return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
|
||||
return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
|
||||
})
|
||||
|
||||
const list = createMemo(() =>
|
||||
sync.data.provider.flatMap((x) => Object.values(x.models).map((m) => ({ providerID: x.id, modelID: m.id }))),
|
||||
)
|
||||
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
|
||||
|
||||
return {
|
||||
list,
|
||||
current,
|
||||
recent() {
|
||||
return store.recent
|
||||
},
|
||||
parsed: createMemo(() => {
|
||||
const value = current()
|
||||
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,
|
||||
}
|
||||
}),
|
||||
set(model: { providerID: string; modelID: string } | undefined, options?: { recent?: boolean }) {
|
||||
recent,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("model", agent.current().name, model ?? fallback())
|
||||
if (options?.recent && model) {
|
||||
@@ -140,11 +125,36 @@ function init() {
|
||||
return store.node[store.active]
|
||||
})
|
||||
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
||||
const changes = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
const status = (path: string) => sync.data.changes.find((f) => f.path === path)
|
||||
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||
|
||||
createEffect((prev: FileStatus[]) => {
|
||||
const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
|
||||
for (const p of removed) {
|
||||
setStore(
|
||||
"node",
|
||||
p.path,
|
||||
produce((draft) => {
|
||||
draft.status = undefined
|
||||
draft.view = "raw"
|
||||
}),
|
||||
)
|
||||
load(p.path)
|
||||
}
|
||||
for (const p of sync.data.changes) {
|
||||
if (store.node[p.path] === undefined) {
|
||||
fetch(p.path).then(() => setStore("node", p.path, "status", p))
|
||||
} else {
|
||||
setStore("node", p.path, "status", p)
|
||||
}
|
||||
}
|
||||
return sync.data.changes
|
||||
}, sync.data.changes)
|
||||
|
||||
const changed = (path: string) => {
|
||||
const set = changes()
|
||||
const node = store.node[path]
|
||||
if (node?.status) return true
|
||||
const set = changeset()
|
||||
if (set.has(path)) return true
|
||||
for (const p of set) {
|
||||
if (p.startsWith(path ? path + "/" : "")) return true
|
||||
@@ -153,50 +163,50 @@ function init() {
|
||||
}
|
||||
|
||||
const resetNode = (path: string) => {
|
||||
setStore("node", path, {
|
||||
loaded: undefined,
|
||||
pinned: undefined,
|
||||
content: undefined,
|
||||
selection: undefined,
|
||||
scrollTop: undefined,
|
||||
folded: undefined,
|
||||
view: undefined,
|
||||
selectedChange: undefined,
|
||||
})
|
||||
setStore("node", path, undefined!)
|
||||
}
|
||||
|
||||
const load = async (path: string) =>
|
||||
sdk.file.read({ query: { path } }).then((x) => {
|
||||
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
|
||||
|
||||
const load = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
sdk.file.read({ query: { path: relativePath } }).then((x) => {
|
||||
setStore(
|
||||
"node",
|
||||
path,
|
||||
relativePath,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.content = x.data
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const open = async (path: string) => {
|
||||
const relative = path.replace(sync.data.path.directory + "/", "")
|
||||
if (!store.node[relative]) {
|
||||
const parent = relative.split("/").slice(0, -1).join("/")
|
||||
if (parent) {
|
||||
await list(parent)
|
||||
}
|
||||
const fetch = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
const parent = relativePath.split("/").slice(0, -1).join("/")
|
||||
if (parent) {
|
||||
await list(parent)
|
||||
}
|
||||
}
|
||||
|
||||
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(relative)) return x
|
||||
if (x.includes(relativePath)) return x
|
||||
return [
|
||||
...opened()
|
||||
.filter((x) => x.pinned)
|
||||
.map((x) => x.path),
|
||||
relative,
|
||||
relativePath,
|
||||
]
|
||||
})
|
||||
setStore("active", relative)
|
||||
if (store.node[relative].loaded) return
|
||||
return load(relative)
|
||||
setStore("active", relativePath)
|
||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||
if (store.node[relativePath].loaded) return
|
||||
return load(relativePath)
|
||||
}
|
||||
|
||||
const list = async (path: string) => {
|
||||
@@ -213,27 +223,31 @@ function init() {
|
||||
})
|
||||
}
|
||||
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
switch (event.type) {
|
||||
case "message.part.updated":
|
||||
const part = event.properties.part
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
switch (part.tool) {
|
||||
case "read":
|
||||
console.log("read", part.state.input)
|
||||
break
|
||||
case "edit":
|
||||
const absolute = part.state.input["filePath"] as string
|
||||
const path = absolute.replace(sync.data.path.directory + "/", "")
|
||||
load(path)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
const search = (query: string) => sdk.find.files({ query: { query } }).then((x) => x.data!)
|
||||
|
||||
const bus = useEvent()
|
||||
bus.listen((event) => {
|
||||
switch (event.type) {
|
||||
case "message.part.updated":
|
||||
const part = event.properties.part
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
switch (part.tool) {
|
||||
case "read":
|
||||
break
|
||||
case "edit":
|
||||
// load(part.state.input["filePath"] as string)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
case "file.watcher.updated":
|
||||
setTimeout(sync.load.changes, 1000)
|
||||
const relativePath = relative(event.properties.file)
|
||||
if (relativePath.startsWith(".git/")) return
|
||||
load(relativePath)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
@@ -306,8 +320,8 @@ function init() {
|
||||
setChangeIndex(path: string, index: number | undefined) {
|
||||
setStore("node", path, "selectedChange", index)
|
||||
},
|
||||
changes,
|
||||
changed,
|
||||
status,
|
||||
children(path: string) {
|
||||
return Object.values(store.node).filter(
|
||||
(x) =>
|
||||
@@ -316,6 +330,8 @@ function init() {
|
||||
!x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
|
||||
)
|
||||
},
|
||||
search,
|
||||
relative,
|
||||
}
|
||||
})()
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { useShiki } from "@/context"
|
||||
import { marked } from "marked"
|
||||
import markedShiki from "marked-shiki"
|
||||
import { bundledLanguages, type BundledLanguage } from "shiki"
|
||||
|
||||
function init(highlighter: ReturnType<typeof useShiki>) {
|
||||
return marked.use(
|
||||
markedShiki({
|
||||
async highlight(code, lang) {
|
||||
if (!(lang in bundledLanguages)) {
|
||||
lang = "text"
|
||||
}
|
||||
if (!highlighter.getLoadedLanguages().includes(lang)) {
|
||||
await highlighter.loadLanguage(lang as BundledLanguage)
|
||||
}
|
||||
return highlighter.codeToHtml(code, {
|
||||
lang: lang || "text",
|
||||
theme: "opencode",
|
||||
tabindex: false,
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
type MarkedContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<MarkedContext>()
|
||||
|
||||
export function MarkedProvider(props: ParentProps) {
|
||||
const highlighter = useShiki()
|
||||
const value = init(highlighter)
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useMarked() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useMarked must be used within a MarkedProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import { createHighlighter, type ThemeInput } from "shiki"
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
const theme: ThemeInput = {
|
||||
colors: {
|
||||
"actionBar.toggledBackground": "var(--theme-background-element)",
|
||||
"activityBarBadge.background": "var(--theme-accent)",
|
||||
"checkbox.border": "var(--theme-border)",
|
||||
"editor.background": "transparent",
|
||||
"editor.foreground": "var(--theme-text)",
|
||||
"editor.inactiveSelectionBackground": "var(--theme-background-element)",
|
||||
"editor.selectionHighlightBackground": "var(--theme-border-active)",
|
||||
"editorIndentGuide.activeBackground1": "var(--theme-border-subtle)",
|
||||
"editorIndentGuide.background1": "var(--theme-border-subtle)",
|
||||
"input.placeholderForeground": "var(--theme-text-muted)",
|
||||
"list.activeSelectionIconForeground": "var(--theme-text)",
|
||||
"list.dropBackground": "var(--theme-background-element)",
|
||||
"menu.background": "var(--theme-background-panel)",
|
||||
"menu.border": "var(--theme-border)",
|
||||
"menu.foreground": "var(--theme-text)",
|
||||
"menu.selectionBackground": "var(--theme-primary)",
|
||||
"menu.separatorBackground": "var(--theme-border)",
|
||||
"ports.iconRunningProcessForeground": "var(--theme-success)",
|
||||
"sideBarSectionHeader.background": "transparent",
|
||||
"sideBarSectionHeader.border": "var(--theme-border-subtle)",
|
||||
"sideBarTitle.foreground": "var(--theme-text-muted)",
|
||||
"statusBarItem.remoteBackground": "var(--theme-success)",
|
||||
"statusBarItem.remoteForeground": "var(--theme-text)",
|
||||
"tab.lastPinnedBorder": "var(--theme-border-subtle)",
|
||||
"tab.selectedBackground": "var(--theme-background-element)",
|
||||
"tab.selectedForeground": "var(--theme-text-muted)",
|
||||
"terminal.inactiveSelectionBackground": "var(--theme-background-element)",
|
||||
"widget.border": "var(--theme-border)",
|
||||
},
|
||||
displayName: "opencode",
|
||||
name: "opencode",
|
||||
semanticHighlighting: true,
|
||||
semanticTokenColors: {
|
||||
customLiteral: "var(--theme-syntax-function)",
|
||||
newOperator: "var(--theme-syntax-operator)",
|
||||
numberLiteral: "var(--theme-syntax-number)",
|
||||
stringLiteral: "var(--theme-syntax-string)",
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: [
|
||||
"meta.embedded",
|
||||
"source.groovy.embedded",
|
||||
"string meta.image.inline.markdown",
|
||||
"variable.legacy.builtin.python",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "emphasis",
|
||||
settings: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "strong",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "header",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-heading)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "comment",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-comment)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.language",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"variable.other.enummember",
|
||||
"keyword.operator.plus.exponent",
|
||||
"keyword.operator.minus.exponent",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.name.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.tag.css", "entity.name.tag.less"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.other.attribute-name",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"entity.other.attribute-name.class.css",
|
||||
"source.css entity.other.attribute-name.class",
|
||||
"entity.other.attribute-name.id.css",
|
||||
"entity.other.attribute-name.parent-selector.css",
|
||||
"entity.other.attribute-name.parent.less",
|
||||
"source.css entity.other.attribute-name.pseudo-class",
|
||||
"entity.other.attribute-name.pseudo-element.css",
|
||||
"source.css.less entity.other.attribute-name.id",
|
||||
"entity.other.attribute-name.scss",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "invalid",
|
||||
settings: {
|
||||
foreground: "var(--theme-error)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.underline",
|
||||
settings: {
|
||||
fontStyle: "underline",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.bold",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
foreground: "var(--theme-markdown-strong)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.heading",
|
||||
settings: {
|
||||
fontStyle: "bold",
|
||||
foreground: "var(--theme-markdown-heading)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.italic",
|
||||
settings: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.strikethrough",
|
||||
settings: {
|
||||
fontStyle: "strikethrough",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.inserted",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-added)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.deleted",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-removed)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.changed",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-context)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.quote.begin.markdown",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-block-quote)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.list.begin.markdown",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-list-enumeration)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "markup.inline.raw",
|
||||
settings: {
|
||||
foreground: "var(--theme-markdown-code)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "punctuation.definition.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-punctuation)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.preprocessor", "entity.name.function.preprocessor"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.preprocessor.string",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.preprocessor.numeric",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.structure.dictionary.key.python",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "meta.diff.header",
|
||||
settings: {
|
||||
foreground: "var(--theme-diff-hunk-header)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "storage",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "storage.type",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["storage.modifier", "keyword.operator.noexcept"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["string", "meta.embedded.assembly"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.tag",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.value",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "string.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.template-expression.begin",
|
||||
"punctuation.definition.template-expression.end",
|
||||
"punctuation.section.embedded",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.template.expression"],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.type.vendored.property-name",
|
||||
"support.type.property-name",
|
||||
"source.css variable",
|
||||
"source.coffee.embedded",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.control",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.operator",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"keyword.operator.new",
|
||||
"keyword.operator.expression",
|
||||
"keyword.operator.cast",
|
||||
"keyword.operator.sizeof",
|
||||
"keyword.operator.alignof",
|
||||
"keyword.operator.typeid",
|
||||
"keyword.operator.alignas",
|
||||
"keyword.operator.instanceof",
|
||||
"keyword.operator.logical.python",
|
||||
"keyword.operator.wordlike",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.other.unit",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "support.function.git-rebase",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.sha.git-rebase",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"],
|
||||
settings: {
|
||||
foreground: "var(--theme-text)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "variable.language",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"entity.name.function",
|
||||
"support.function",
|
||||
"support.constant.handlebars",
|
||||
"source.powershell variable.other.member",
|
||||
"entity.name.operator.custom-literal",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-function)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.class",
|
||||
"support.type",
|
||||
"entity.name.type",
|
||||
"entity.name.namespace",
|
||||
"entity.other.attribute",
|
||||
"entity.name.scope-resolution",
|
||||
"entity.name.class",
|
||||
"storage.type.numeric.go",
|
||||
"storage.type.byte.go",
|
||||
"storage.type.boolean.go",
|
||||
"storage.type.string.go",
|
||||
"storage.type.uintptr.go",
|
||||
"storage.type.error.go",
|
||||
"storage.type.rune.go",
|
||||
"storage.type.cs",
|
||||
"storage.type.generic.cs",
|
||||
"storage.type.modifier.cs",
|
||||
"storage.type.variable.cs",
|
||||
"storage.type.annotation.java",
|
||||
"storage.type.generic.java",
|
||||
"storage.type.java",
|
||||
"storage.type.object.array.java",
|
||||
"storage.type.primitive.array.java",
|
||||
"storage.type.primitive.java",
|
||||
"storage.type.token.java",
|
||||
"storage.type.groovy",
|
||||
"storage.type.annotation.groovy",
|
||||
"storage.type.parameters.groovy",
|
||||
"storage.type.generic.groovy",
|
||||
"storage.type.object.array.groovy",
|
||||
"storage.type.primitive.array.groovy",
|
||||
"storage.type.primitive.groovy",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-type)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"meta.type.cast.expr",
|
||||
"meta.type.new.expr",
|
||||
"support.constant.math",
|
||||
"support.constant.dom",
|
||||
"support.constant.json",
|
||||
"entity.other.inherited-class",
|
||||
"punctuation.separator.namespace.ruby",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-type)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"keyword.control",
|
||||
"source.cpp keyword.operator.new",
|
||||
"keyword.operator.delete",
|
||||
"keyword.other.using",
|
||||
"keyword.other.directive.using",
|
||||
"keyword.other.operator",
|
||||
"entity.name.operator",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"variable",
|
||||
"meta.definition.variable.name",
|
||||
"support.variable",
|
||||
"entity.name.variable",
|
||||
"constant.other.placeholder",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["variable.other.constant", "variable.other.enummember"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["meta.object-literal.key"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.constant.property-value",
|
||||
"support.constant.font-name",
|
||||
"support.constant.media-type",
|
||||
"support.constant.media",
|
||||
"constant.other.color.rgb-value",
|
||||
"constant.other.rgb-value",
|
||||
"support.constant.color",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.group.regexp",
|
||||
"punctuation.definition.group.assertion.regexp",
|
||||
"punctuation.definition.character-class.regexp",
|
||||
"punctuation.character.set.begin.regexp",
|
||||
"punctuation.character.set.end.regexp",
|
||||
"keyword.operator.negation.regexp",
|
||||
"support.other.parenthesis.regexp",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.character.character-class.regexp",
|
||||
"constant.other.character-class.set.regexp",
|
||||
"constant.other.character-class.regexp",
|
||||
"constant.character.set.regexp",
|
||||
],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "keyword.operator.quantifier.regexp",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["constant.character", "constant.other.option"],
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "constant.character.escape",
|
||||
settings: {
|
||||
foreground: "var(--theme-syntax-operator)",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "entity.name.label",
|
||||
settings: {
|
||||
foreground: "var(--theme-text-muted)",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "dark",
|
||||
}
|
||||
|
||||
const highlighter = await createHighlighter({
|
||||
themes: [theme],
|
||||
langs: [],
|
||||
})
|
||||
|
||||
type ShikiContext = typeof highlighter
|
||||
|
||||
const ctx = createContext<ShikiContext>()
|
||||
|
||||
export function ShikiProvider(props: ParentProps) {
|
||||
return <ctx.Provider value={highlighter}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useShiki() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useShiki must be used within a ShikiProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode } from "@opencode-ai/sdk"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useSDK } from "./sdk"
|
||||
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
||||
import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js"
|
||||
import { useSDK, useEvent } from "@/context"
|
||||
import { Binary } from "@/utils/binary"
|
||||
|
||||
function init() {
|
||||
@@ -33,83 +33,88 @@ function init() {
|
||||
changes: [],
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
switch (event.type) {
|
||||
case "session.updated": {
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
const bus = useEvent()
|
||||
bus.listen((event) => {
|
||||
switch (event.type) {
|
||||
case "session.updated": {
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
case "message.updated": {
|
||||
const messages = store.message[event.properties.info.sessionID]
|
||||
if (!messages) {
|
||||
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.updated": {
|
||||
const messages = store.message[event.properties.info.sessionID]
|
||||
if (!messages) {
|
||||
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.part)
|
||||
}),
|
||||
)
|
||||
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.part)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Promise.all([
|
||||
sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
|
||||
sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||
sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
sdk.session.list().then((x) =>
|
||||
setStore(
|
||||
"session",
|
||||
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
const sdk = useSDK()
|
||||
|
||||
const load = {
|
||||
provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
|
||||
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
session: () =>
|
||||
sdk.session.list().then((x) =>
|
||||
setStore(
|
||||
"session",
|
||||
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
sdk.file.status().then((x) => setStore("changes", x.data!)),
|
||||
sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
|
||||
]).then(() => setStore("ready", true))
|
||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
|
||||
node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
|
||||
}
|
||||
|
||||
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
|
||||
|
||||
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
|
||||
const sanitize = (text: string) => text.replace(sanitizer(), "")
|
||||
|
||||
return {
|
||||
data: store,
|
||||
@@ -140,6 +145,8 @@ function init() {
|
||||
)
|
||||
},
|
||||
},
|
||||
load,
|
||||
sanitize,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,19 @@
|
||||
/* color: var(--color-background); */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--theme-background-panel);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--theme-border-subtle);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-color: var(--theme-border-subtle) var(--theme-background-panel);
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
|
||||
+24
-10
@@ -4,7 +4,15 @@ import { Router, Route } from "@solidjs/router"
|
||||
import "@/index.css"
|
||||
import Layout from "@/pages/layout"
|
||||
import Home from "@/pages"
|
||||
import { SDKProvider, SyncProvider, LocalProvider, ThemeProvider } from "@/context"
|
||||
import {
|
||||
EventProvider,
|
||||
SDKProvider,
|
||||
SyncProvider,
|
||||
LocalProvider,
|
||||
ThemeProvider,
|
||||
ShikiProvider,
|
||||
MarkedProvider,
|
||||
} from "@/context"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
|
||||
@@ -18,15 +26,21 @@ render(
|
||||
() => (
|
||||
<div class="h-full bg-background text-text-muted">
|
||||
<ThemeProvider defaultTheme="opencode" defaultDarkMode={true}>
|
||||
<SDKProvider>
|
||||
<SyncProvider>
|
||||
<LocalProvider>
|
||||
<Router root={Layout}>
|
||||
<Route path="/" component={Home} />
|
||||
</Router>
|
||||
</LocalProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
<ShikiProvider>
|
||||
<MarkedProvider>
|
||||
<SDKProvider>
|
||||
<EventProvider>
|
||||
<SyncProvider>
|
||||
<LocalProvider>
|
||||
<Router root={Layout}>
|
||||
<Route path="/" component={Home} />
|
||||
</Router>
|
||||
</LocalProvider>
|
||||
</SyncProvider>
|
||||
</EventProvider>
|
||||
</SDKProvider>
|
||||
</MarkedProvider>
|
||||
</ShikiProvider>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
|
||||
import { Button, FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
|
||||
import { Tabs } from "@/ui/tabs"
|
||||
import { Select } from "@/components/select"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { SelectDialog } from "@/components/select-dialog"
|
||||
import { useLocal, useSDK } from "@/context"
|
||||
import { Code } from "@/components/code"
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import type { LocalFile } from "@/context/local"
|
||||
import SessionList from "@/components/session-list"
|
||||
import SessionTimeline from "@/components/session-timeline"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
|
||||
export default function Page() {
|
||||
const sdk = useSDK()
|
||||
@@ -28,6 +30,8 @@ export default function Page() {
|
||||
activeItem: undefined as string | undefined,
|
||||
prompt: "",
|
||||
dragging: undefined as "left" | "right" | undefined,
|
||||
modelSelectOpen: false,
|
||||
fileSelectOpen: false,
|
||||
})
|
||||
|
||||
let inputRef: HTMLInputElement | undefined = undefined
|
||||
@@ -43,6 +47,17 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.getModifierState(MOD) && e.shiftKey && e.key.toLowerCase() === "p") {
|
||||
e.preventDefault()
|
||||
// TODO: command palette
|
||||
return
|
||||
}
|
||||
if (e.getModifierState(MOD) && e.key.toLowerCase() === "p") {
|
||||
e.preventDefault()
|
||||
setStore("fileSelectOpen", true)
|
||||
return
|
||||
}
|
||||
|
||||
const inputFocused = document.activeElement === inputRef
|
||||
if (inputFocused) {
|
||||
if (e.key === "Escape") {
|
||||
@@ -190,7 +205,7 @@ export default function Page() {
|
||||
path: { id: session!.id },
|
||||
body: {
|
||||
agent: local.agent.current()!.name,
|
||||
model: local.model.current(),
|
||||
model: { modelID: local.model.current()!.id, providerID: local.model.current()!.provider.id },
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
@@ -226,7 +241,7 @@ export default function Page() {
|
||||
return (
|
||||
<div class="relative">
|
||||
<div
|
||||
class="fixed top-0 left-0 h-full border-r border-border-subtle/30 flex flex-col overflow-hidden"
|
||||
class="fixed top-0 left-0 h-full border-r border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10"
|
||||
style={`width: ${local.layout.leftWidth()}px`}
|
||||
>
|
||||
<Tabs class="relative flex flex-col h-full" defaultValue="files">
|
||||
@@ -244,7 +259,29 @@ export default function Page() {
|
||||
<FileTree path="" onFileClick={handleFileClick} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
|
||||
<div class="px-2 text-xs text-text-muted">No changes yet</div>
|
||||
<Show
|
||||
when={local.file.changes().length}
|
||||
fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
|
||||
>
|
||||
<ul class="">
|
||||
<For each={local.file.changes()}>
|
||||
{(path) => (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
|
||||
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 cursor-pointer hover:bg-background-element"
|
||||
>
|
||||
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
|
||||
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
|
||||
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
|
||||
{getDirectory(path)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -262,10 +299,10 @@ export default function Page() {
|
||||
</div>
|
||||
<Show when={local.layout.rightPane()}>
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden"
|
||||
class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10"
|
||||
style={`width: ${local.layout.rightWidth()}px`}
|
||||
>
|
||||
<div class="relative flex-1 min-h-0 overflow-y-auto no-scrollbar">
|
||||
<div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||
<Show when={local.session.active()} fallback={<SessionList />}>
|
||||
{(activeSession) => (
|
||||
<div class="relative">
|
||||
@@ -470,7 +507,7 @@ export default function Page() {
|
||||
type="text"
|
||||
value={store.prompt}
|
||||
onInput={(e) => setStore("prompt", e.currentTarget.value)}
|
||||
placeholder="It all starts with a prompt..."
|
||||
placeholder="Placeholder text..."
|
||||
class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none"
|
||||
/>
|
||||
<div class="flex justify-between items-center text-xs text-text-muted">
|
||||
@@ -479,24 +516,13 @@ export default function Page() {
|
||||
options={local.agent.list().map((a) => a.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
size="sm"
|
||||
class="uppercase"
|
||||
/>
|
||||
<Select
|
||||
options={local.model.list()}
|
||||
current={local.model.current()}
|
||||
onSelect={local.model.set}
|
||||
label={(x) => x.modelID}
|
||||
value={(x) => `${x.providerID}.${x.modelID}`}
|
||||
filter={{
|
||||
keys: ["providerID", "modelID"],
|
||||
placeholder: "Filter models",
|
||||
}}
|
||||
groupBy={(x) => x.providerID}
|
||||
size="sm"
|
||||
class="uppercase"
|
||||
/>
|
||||
<span class="text-text-muted/70">{local.model.parsed().provider}</span>
|
||||
<Button onClick={() => setStore("modelSelectOpen", true)}>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size={24} class="text-text-muted" />
|
||||
</Button>
|
||||
<span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span>
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost">
|
||||
@@ -510,29 +536,94 @@ export default function Page() {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<Show when={store.modelSelectOpen}>
|
||||
<SelectDialog
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
items={local.model.list()}
|
||||
current={local.model.current()}
|
||||
render={(i) => (
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
|
||||
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-4 invert opacity-40" />
|
||||
<span class="text-xs text-text whitespace-nowrap">{i.name}</span>
|
||||
<span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{i.id}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0">
|
||||
<Tooltip forceMount={false} value="Reasoning">
|
||||
<Icon name="brain" size={16} classList={{ "text-accent": i.reasoning }} />
|
||||
</Tooltip>
|
||||
<Tooltip forceMount={false} value="Tools">
|
||||
<Icon name="hammer" size={16} classList={{ "text-secondary": i.tool_call }} />
|
||||
</Tooltip>
|
||||
<Tooltip forceMount={false} value="Attachments">
|
||||
<Icon name="photo" size={16} classList={{ "text-success": i.attachment }} />
|
||||
</Tooltip>
|
||||
<div class="rounded-full bg-text-muted/20 text-text-muted/80 w-9 h-4 flex items-center justify-center text-[10px]">
|
||||
{new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(i.limit.context)}
|
||||
</div>
|
||||
<Tooltip forceMount={false} value={`$${i.cost?.input}/1M input, $${i.cost?.output}/1M output`}>
|
||||
<div class="rounded-full bg-success/20 text-success/80 w-9 h-4 flex items-center justify-center text-[10px]">
|
||||
<Switch fallback="FREE">
|
||||
<Match when={i.cost?.input > 10}>$$$</Match>
|
||||
<Match when={i.cost?.input > 1}>$$</Match>
|
||||
<Match when={i.cost?.input > 0.1}>$</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
filter={["provider.name", "name", "id"]}
|
||||
groupBy={(x) => x.provider.name}
|
||||
onClose={() => setStore("modelSelectOpen", false)}
|
||||
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={store.fileSelectOpen}>
|
||||
<SelectDialog
|
||||
items={local.file.search}
|
||||
key={(x) => x}
|
||||
render={(i) => (
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<span class="text-xs text-text whitespace-nowrap">{getFilename(i)}</span>
|
||||
<span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||
</div>
|
||||
)}
|
||||
onClose={() => setStore("fileSelectOpen", false)}
|
||||
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TabVisual = (props: { file: LocalFile }) => {
|
||||
const local = useLocal()
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon node={props.file} class="" />
|
||||
<span
|
||||
classList={{ "text-xs": true, "text-primary": local.file.changed(props.file.path), italic: !props.file.pinned }}
|
||||
>
|
||||
<span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}>
|
||||
{props.file.name}
|
||||
</span>
|
||||
<span class="text-xs opacity-70">
|
||||
<Switch>
|
||||
<Match when={local.file.status(props.file.path)?.status === "modified"}>
|
||||
<Match when={props.file.status?.status === "modified"}>
|
||||
<span class="text-primary">M</span>
|
||||
</Match>
|
||||
<Match when={local.file.status(props.file.path)?.status === "added"}>
|
||||
<Match when={props.file.status?.status === "added"}>
|
||||
<span class="text-success">A</span>
|
||||
</Match>
|
||||
<Match when={local.file.status(props.file.path)?.status === "deleted"}>
|
||||
<Match when={props.file.status?.status === "deleted"}>
|
||||
<span class="text-error">D</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -1,49 +1,36 @@
|
||||
import { Button as KobalteButton } from "@kobalte/core/button"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { Button as Kobalte } from "@kobalte/core/button"
|
||||
import { type ComponentProps, splitProps } from "solid-js"
|
||||
|
||||
export interface ButtonProps extends ComponentProps<typeof KobalteButton> {
|
||||
variant?: "primary" | "secondary" | "outline" | "ghost"
|
||||
export interface ButtonProps {
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
size?: "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
export const buttonStyles = {
|
||||
base: "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
|
||||
variants: {
|
||||
primary: "bg-primary text-background hover:bg-secondary focus-visible:ring-primary data-[disabled]:opacity-50",
|
||||
secondary:
|
||||
"bg-background-panel text-text hover:bg-background-element focus-visible:ring-secondary data-[disabled]:opacity-50",
|
||||
outline:
|
||||
"border border-border bg-transparent text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:border-border-subtle data-[disabled]:text-text-muted",
|
||||
ghost: "text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:text-text-muted",
|
||||
},
|
||||
sizes: {
|
||||
sm: "h-8 px-3 text-sm",
|
||||
md: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
},
|
||||
}
|
||||
|
||||
export function getButtonClasses(
|
||||
variant: keyof typeof buttonStyles.variants = "primary",
|
||||
size: keyof typeof buttonStyles.sizes = "md",
|
||||
className?: string,
|
||||
) {
|
||||
return `${buttonStyles.base} ${buttonStyles.variants[variant]} ${buttonStyles.sizes[size]}${className ? ` ${className}` : ""}`
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
const [local, others] = splitProps(props, ["variant", "size", "class", "classList"])
|
||||
export function Button(props: ComponentProps<"button"> & ButtonProps) {
|
||||
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
|
||||
return (
|
||||
<KobalteButton
|
||||
<Kobalte
|
||||
{...rest}
|
||||
data-size={split.size || "sm"}
|
||||
data-variant={split.variant || "secondary"}
|
||||
class="inline-flex items-center justify-center rounded-md cursor-pointer font-medium transition-colors
|
||||
min-w-0 whitespace-nowrap truncate
|
||||
data-[size=sm]:h-6 data-[size=sm]:pl-2 data-[size=sm]:text-xs
|
||||
data-[size=md]:h-8 data-[size=md]:pl-3 data-[size=md]:text-sm
|
||||
data-[size=lg]:h-10 data-[size=lg]:pl-4 data-[size=lg]:text-base
|
||||
data-[variant=primary]:bg-primary data-[variant=primary]:text-background
|
||||
data-[variant=primary]:hover:bg-secondary data-[variant=primary]:focus-visible:ring-primary
|
||||
data-[variant=secondary]:bg-background-element data-[variant=secondary]:text-text
|
||||
data-[variant=secondary]:hover:bg-background-element data-[variant=secondary]:focus-visible:ring-secondary
|
||||
data-[variant=ghost]:text-text data-[variant=ghost]:hover:bg-background-panel data-[variant=ghost]:focus-visible:ring-border-active
|
||||
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-transparent
|
||||
disabled:pointer-events-none disabled:opacity-50"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[buttonStyles.base]: true,
|
||||
[buttonStyles.variants[local.variant || "primary"]]: true,
|
||||
[buttonStyles.sizes[local.size || "md"]]: true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
{...others}
|
||||
/>
|
||||
>
|
||||
{props.children}
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,6 +125,11 @@ const icons = {
|
||||
columns: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.75 6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H17.25C18.3546 4.75 19.25 5.64543 19.25 6.75V17.25C19.25 18.3546 18.3546 19.25 17.25 19.25H6.75C5.64543 19.25 4.75 18.3546 4.75 17.25V6.75Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 5V19"></path>',
|
||||
"open-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.25 4.75v14.5m-3-8.5L9.75 12l1.5 1.25m-4.5 6h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
||||
"close-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 4.75v14.5m3-8.5L14.25 12l-1.5 1.25M6.75 19.25h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
||||
"file-search": '<path fill="currentColor" d="M17.25 9.25V10a.75.75 0 0 0 .53-1.28l-.53.53Zm-4.5-4.5.53-.53a.75.75 0 0 0-.53-.22v.75ZM10.25 20a.75.75 0 0 0 0-1.5V20Zm7.427-3.383a.75.75 0 0 0-1.06 1.06l1.06-1.06Zm1.043 3.163a.75.75 0 1 0 1.06-1.06l-1.06 1.06Zm-.94-11.06-4.5-4.5-1.06 1.06 4.5 4.5 1.06-1.06ZM12.75 4h-6v1.5h6V4ZM4 6.75v10.5h1.5V6.75H4ZM6.75 20h3.5v-1.5h-3.5V20ZM12 4.75v3.5h1.5v-3.5H12ZM13.75 10h3.5V8.5h-3.5V10ZM12 8.25c0 .966.784 1.75 1.75 1.75V8.5a.25.25 0 0 1-.25-.25H12Zm-8 9A2.75 2.75 0 0 0 6.75 20v-1.5c-.69 0-1.25-.56-1.25-1.25H4ZM6.75 4A2.75 2.75 0 0 0 4 6.75h1.5c0-.69.56-1.25 1.25-1.25V4Zm8.485 14.47a3.235 3.235 0 0 0 3.236-3.235h-1.5c0 .959-.777 1.736-1.736 1.736v1.5Zm0-4.97c.959 0 1.736.777 1.736 1.735h1.5A3.235 3.235 0 0 0 15.235 12v1.5Zm0-1.5A3.235 3.235 0 0 0 12 15.235h1.5c0-.958.777-1.735 1.735-1.735V12Zm0 4.97a1.735 1.735 0 0 1-1.735-1.735H12a3.235 3.235 0 0 0 3.235 3.236v-1.5Zm1.382.707 2.103 2.103 1.06-1.06-2.103-2.103-1.06 1.06Z"></path>',
|
||||
"folder-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.25 19.25h-3.5a2 2 0 0 1-2-2v-9.5h12.5a2 2 0 0 1 2 2v.5m-5.75-2.5-.931-1.958a2 2 0 0 0-1.756-1.042H6.75a2 2 0 0 0-2 2V11m12.695 6.445 1.805 1.805m-3.75-1a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5Z"></path>',
|
||||
search: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 19.25L15.5 15.5M4.75 11C4.75 7.54822 7.54822 4.75 11 4.75C14.4518 4.75 17.25 7.54822 17.25 11C17.25 14.4518 14.4518 17.25 11 17.25C7.54822 17.25 4.75 14.4518 4.75 11Z"></path>',
|
||||
"web-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 8.25v-.5a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v.5m14.5 0H4.75m14.5 0v2m-14.5-2v8a2 2 0 0 0 2 2h2.5m7.743-1.257 2.257 2.257m-4.015-1.53a2.485 2.485 0 1 0 0-4.97 2.485 2.485 0 0 0 0 4.97Z"></path>',
|
||||
loading: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.75v1.5m5.126.624L16 8m3.25 4h-1.5m-.624 5.126-1.768-1.768M12 16.75v2.5m-3.36-3.891-1.768 1.768M7.25 12h-2.5m3.891-3.358L6.874 6.874"></path>',
|
||||
} as const
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { A } from "@solidjs/router"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { getButtonClasses } from "./button"
|
||||
|
||||
export interface LinkProps extends ComponentProps<typeof A> {
|
||||
variant?: "primary" | "secondary" | "outline" | "ghost"
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
size?: "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
export function Link(props: LinkProps) {
|
||||
const [local, others] = splitProps(props, ["variant", "size", "class"])
|
||||
const classes = local.variant ? getButtonClasses(local.variant, local.size, local.class) : local.class
|
||||
return <A class={classes} {...others} />
|
||||
const [, others] = splitProps(props, ["variant", "size", "class"])
|
||||
return <A {...others} />
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function Tooltip(props: TooltipProps) {
|
||||
<KobalteTooltip.Portal>
|
||||
<KobalteTooltip.Content
|
||||
classList={{
|
||||
"z-50 max-w-[320px] rounded-md bg-background-element px-2 py-1": true,
|
||||
"z-[1000] max-w-[320px] rounded-md bg-background-element px-2 py-1": true,
|
||||
"text-xs font-medium text-text shadow-md pointer-events-none!": true,
|
||||
"transition-all duration-150 ease-out": true,
|
||||
"transform-gpu transform-origin-[var(--kb-tooltip-content-transform-origin)]": true,
|
||||
|
||||
@@ -3,6 +3,11 @@ export function getFilename(path: string) {
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
export function getDirectory(path: string) {
|
||||
const parts = path.split("/")
|
||||
return parts.slice(0, parts.length - 1).join("/")
|
||||
}
|
||||
|
||||
export function getFileExtension(path: string) {
|
||||
const parts = path.split(".")
|
||||
return parts[parts.length - 1]
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"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.10.5"
|
||||
"version": "0.12.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.15.0",
|
||||
"@solidjs/start": "^1.1.0",
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Billing } from "@opencode/console-core/billing.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { IconCreditCard } from "~/component/icon"
|
||||
import styles from "./billing-section.module.css"
|
||||
import { Database, eq } from "@opencode/console-core/drizzle/index.js"
|
||||
import { BillingTable } from "@opencode/console-core/schema/billing.sql.js"
|
||||
|
||||
const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
|
||||
"use server"
|
||||
@@ -17,12 +19,23 @@ const reload = action(async (form: FormData) => {
|
||||
return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key })
|
||||
}, "billing.reload")
|
||||
|
||||
const disableReload = action(async (form: FormData) => {
|
||||
const setReload = 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.disableReload(), workspaceID), { revalidate: getBillingInfo.key })
|
||||
}, "billing.disableReload")
|
||||
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")
|
||||
|
||||
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||
"use server"
|
||||
@@ -44,7 +57,7 @@ export function BillingSection() {
|
||||
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
|
||||
const createSessionUrlAction = useAction(createSessionUrl)
|
||||
const createSessionUrlSubmission = useSubmission(createSessionUrl)
|
||||
const disableReloadSubmission = useSubmission(disableReload)
|
||||
const setReloadSubmission = useSubmission(setReload)
|
||||
const reloadSubmission = useSubmission(reload)
|
||||
|
||||
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
|
||||
@@ -89,6 +102,10 @@ 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">
|
||||
@@ -136,19 +153,32 @@ export function BillingSection() {
|
||||
<Show
|
||||
when={balanceInfo()?.reload}
|
||||
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
|
||||
}
|
||||
}}
|
||||
<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>
|
||||
}
|
||||
>
|
||||
{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>
|
||||
}
|
||||
>
|
||||
<button
|
||||
@@ -164,10 +194,11 @@ export function BillingSection() {
|
||||
>
|
||||
{createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
|
||||
</button>
|
||||
<form action={disableReload} method="post" data-slot="create-form">
|
||||
<form action={setReload} method="post" data-slot="create-form">
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<button data-color="ghost" type="submit" disabled={disableReloadSubmission.pending}>
|
||||
{disableReloadSubmission.pending ? "Disabling..." : "Disable"}
|
||||
<input type="hidden" name="reload" value="false" />
|
||||
<button data-color="ghost" type="submit" disabled={setReloadSubmission.pending}>
|
||||
{setReloadSubmission.pending ? "Disabling..." : "Disable"}
|
||||
</button>
|
||||
</form>
|
||||
</Show>
|
||||
@@ -176,7 +207,7 @@ export function BillingSection() {
|
||||
<div data-slot="usage">
|
||||
<Show when={!balanceInfo()?.reload}>
|
||||
<Show
|
||||
when={!(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}
|
||||
when={hasBalance()}
|
||||
fallback={
|
||||
<p>
|
||||
We'll load <b>$20</b> (+$1.23 processing fee) and reload it when it reaches <b>$5</b>.
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
|
||||
&[data-slot="payment-amount"] {
|
||||
color: var(--color-text);
|
||||
|
||||
&[data-refunded="true"] {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,9 @@ export function PaymentSection() {
|
||||
{formatDateForTable(date)}
|
||||
</td>
|
||||
<td data-slot="payment-id">{payment.id}</td>
|
||||
<td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td>
|
||||
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
||||
${((payment.amount ?? 0) / 100000000).toFixed(2)}
|
||||
</td>
|
||||
<td data-slot="payment-receipt">
|
||||
<button
|
||||
onClick={async () => {
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function Home() {
|
||||
|
||||
<div data-component="legal">
|
||||
<span>
|
||||
©2025 <a href="https://anoma.ly">Anomaly Innovations</a>
|
||||
©2025 <a href="https://anoma.ly">Anomaly</a>
|
||||
</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Billing } from "@opencode/console-core/billing.js"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Database, eq, sql } from "@opencode/console-core/drizzle/index.js"
|
||||
import { and, Database, eq, sql } from "@opencode/console-core/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable } from "@opencode/console-core/schema/billing.sql.js"
|
||||
import { Identifier } from "@opencode/console-core/identifier.js"
|
||||
import { centsToMicroCents } from "@opencode/console-core/util/price.js"
|
||||
@@ -41,12 +41,14 @@ export async function POST(input: APIEvent) {
|
||||
const workspaceID = body.data.object.metadata?.workspaceID
|
||||
const customerID = body.data.object.customer as string
|
||||
const paymentID = body.data.object.payment_intent as string
|
||||
const invoiceID = body.data.object.invoice as string
|
||||
const amount = body.data.object.amount_total
|
||||
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!amount) throw new Error("Amount not found")
|
||||
if (!paymentID) throw new Error("Payment ID not found")
|
||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
const customer = await Billing.get()
|
||||
@@ -86,11 +88,45 @@ export async function POST(input: APIEvent) {
|
||||
id: Identifier.create("payment"),
|
||||
amount: centsToMicroCents(Billing.CHARGE_AMOUNT),
|
||||
paymentID,
|
||||
invoiceID,
|
||||
customerID,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
if (body.type === "charge.refunded") {
|
||||
const customerID = body.data.object.customer as string
|
||||
const paymentIntentID = body.data.object.payment_intent as string
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!paymentIntentID) throw new Error("Payment ID not found")
|
||||
|
||||
const workspaceID = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
workspaceID: BillingTable.workspaceID,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.customerID, customerID))
|
||||
.then((rows) => rows[0]?.workspaceID),
|
||||
)
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(PaymentTable)
|
||||
.set({
|
||||
timeRefunded: new Date(body.created * 1000),
|
||||
})
|
||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} - ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
})
|
||||
}
|
||||
|
||||
console.log("finished handling")
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Resource } from "@opencode/console-resource"
|
||||
import { Billing } from "../../../../core/src/billing"
|
||||
import { Actor } from "@opencode/console-core/actor.js"
|
||||
import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
|
||||
import { ZenModel } from "@opencode/console-core/model.js"
|
||||
|
||||
export async function handler(
|
||||
input: APIEvent,
|
||||
@@ -34,32 +35,7 @@ export async function handler(
|
||||
class MonthlyLimitError extends Error {}
|
||||
class ModelError extends Error {}
|
||||
|
||||
const ModelCostSchema = z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cacheRead: z.number().optional(),
|
||||
cacheWrite5m: z.number().optional(),
|
||||
cacheWrite1h: z.number().optional(),
|
||||
})
|
||||
|
||||
const ModelSchema = z.object({
|
||||
cost: ModelCostSchema,
|
||||
cost200K: ModelCostSchema.optional(),
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
providers: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
api: z.string(),
|
||||
apiKey: z.string(),
|
||||
model: z.string(),
|
||||
weight: z.number().optional(),
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
type Model = z.infer<typeof ModelSchema>
|
||||
type Model = z.infer<typeof ZenModel.ModelSchema>
|
||||
|
||||
const FREE_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
@@ -147,7 +123,10 @@ export async function handler(
|
||||
return (
|
||||
reader?.read().then(async ({ done, value }) => {
|
||||
if (done) {
|
||||
logger.metric({ response_length: responseLength })
|
||||
logger.metric({
|
||||
response_length: responseLength,
|
||||
"timestamp.last_byte": Date.now(),
|
||||
})
|
||||
const usage = opts.getStreamUsage()
|
||||
if (usage) {
|
||||
await trackUsage(authInfo, modelInfo, providerInfo.id, usage)
|
||||
@@ -158,10 +137,13 @@ export async function handler(
|
||||
}
|
||||
|
||||
if (responseLength === 0) {
|
||||
logger.metric({ time_to_first_byte: Date.now() - startTimestamp })
|
||||
const now = Date.now()
|
||||
logger.metric({
|
||||
time_to_first_byte: now - startTimestamp,
|
||||
"timestamp.first_byte": now,
|
||||
})
|
||||
}
|
||||
responseLength += value.length
|
||||
console.log(decoder.decode(value, { stream: true }))
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const parts = buffer.split("\n\n")
|
||||
@@ -224,7 +206,7 @@ export async function handler(
|
||||
function validateModel(reqModel: string) {
|
||||
const json = JSON.parse(Resource.ZEN_MODELS.value)
|
||||
|
||||
const allModels = z.record(z.string(), ModelSchema).parse(json)
|
||||
const allModels = ZenModel.ModelsSchema.parse(json)
|
||||
|
||||
if (!(reqModel in allModels)) {
|
||||
throw new ModelError(`Model ${reqModel} not supported`)
|
||||
|
||||
@@ -30,22 +30,25 @@ export function POST(input: APIEvent) {
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6)) as { usage?: Usage }
|
||||
json = JSON.parse(data.slice(6))
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!json.usage) return
|
||||
// ie. { type: "message_start"; message: { usage: Usage } }
|
||||
// ie. { type: "message_delta"; usage: Usage }
|
||||
const usageUpdate = json.usage ?? json.message?.usage
|
||||
if (!usageUpdate) return
|
||||
usage = {
|
||||
...usage,
|
||||
...json.usage,
|
||||
...usageUpdate,
|
||||
cache_creation: {
|
||||
...usage?.cache_creation,
|
||||
...json.usage.cache_creation,
|
||||
...usageUpdate.cache_creation,
|
||||
},
|
||||
server_tool_use: {
|
||||
...usage?.server_tool_use,
|
||||
...json.usage.server_tool_use,
|
||||
...usageUpdate.server_tool_use,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `payment` ADD `time_refunded` timestamp(3);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `payment` ADD `invoice_id` varchar(255);
|
||||
@@ -0,0 +1,650 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "45b67fb4-77ce-4aa2-b883-1971429c69f5",
|
||||
"prevId": "7f3989cb-3e8b-430e-a0f5-f87051d1d824",
|
||||
"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
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"email": {
|
||||
"name": "email",
|
||||
"columns": ["email"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"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_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_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
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"actor": {
|
||||
"name": "actor",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"old_name": {
|
||||
"name": "old_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "varchar(255)",
|
||||
"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
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"columns": ["workspace_id", "name"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"key_workspace_id_id_pk": {
|
||||
"name": "key_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
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"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
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email": {
|
||||
"name": "user_email",
|
||||
"columns": ["workspace_id", "email"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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": false,
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "100a21cf-ff9c-476f-bf7d-100c1824b2b2",
|
||||
"prevId": "45b67fb4-77ce-4aa2-b883-1971429c69f5",
|
||||
"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
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"email": {
|
||||
"name": "email",
|
||||
"columns": ["email"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"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_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_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
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"actor": {
|
||||
"name": "actor",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"old_name": {
|
||||
"name": "old_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "varchar(255)",
|
||||
"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
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"columns": ["workspace_id", "name"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"key_workspace_id_id_pk": {
|
||||
"name": "key_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
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"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
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email": {
|
||||
"name": "user_email",
|
||||
"columns": ["workspace_id", "email"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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": false,
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,20 @@
|
||||
"when": 1758428484500,
|
||||
"tag": "0015_cloudy_revanche",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "5",
|
||||
"when": 1758663086739,
|
||||
"tag": "0016_cold_la_nuit",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "5",
|
||||
"when": 1758755183232,
|
||||
"tag": "0017_woozy_thaddeus_ross",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "0.10.5",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"drizzle-orm": "0.41.0",
|
||||
"postgres": "3.4.7",
|
||||
"stripe": "18.0.0",
|
||||
"ulid": "catalog:"
|
||||
"ulid": "3.0.0"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./src/*"
|
||||
@@ -20,6 +20,9 @@
|
||||
"db": "sst shell drizzle-kit",
|
||||
"db-dev": "sst shell --stage dev -- drizzle-kit",
|
||||
"db-prod": "sst shell --stage production -- drizzle-kit",
|
||||
"update-models": "script/update-models.ts",
|
||||
"promote-models-to-dev": "script/promote-models.ts dev",
|
||||
"promote-models-to-prod": "script/promote-models.ts production",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { ZenModel } from "../src/model"
|
||||
|
||||
const stage = process.argv[2]
|
||||
if (!stage) throw new Error("Stage is required")
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||
const value = ret
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("ZEN_MODELS"))
|
||||
?.split("=")[1]
|
||||
if (!value) throw new Error("ZEN_MODELS not found")
|
||||
|
||||
// validate value
|
||||
ZenModel.ModelsSchema.parse(JSON.parse(value))
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_MODELS ${value} --stage ${stage}`
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { ZenModel } from "../src/model"
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const models = await $`bun sst secret list`.cwd(root).text()
|
||||
console.log("models", models)
|
||||
|
||||
// read the line starting with "ZEN_MODELS"
|
||||
const oldValue = models
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("ZEN_MODELS"))
|
||||
?.split("=")[1]
|
||||
if (!oldValue) throw new Error("ZEN_MODELS not found")
|
||||
console.log("oldValue", oldValue)
|
||||
|
||||
// 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))
|
||||
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())
|
||||
ZenModel.ModelsSchema.parse(newValue)
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_MODELS ${JSON.stringify(newValue)}`
|
||||
@@ -10,6 +10,8 @@ import { Identifier } from "./identifier"
|
||||
import { centsToMicroCents } from "./util/price"
|
||||
|
||||
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_FEE = 123 // Stripe fee 4.4% + $0.30
|
||||
export const CHARGE_THRESHOLD = 500 // $5
|
||||
@@ -73,22 +75,39 @@ export namespace Billing {
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
const paymentID = Identifier.create("payment")
|
||||
let charge
|
||||
let invoice
|
||||
try {
|
||||
charge = await Billing.stripe().paymentIntents.create(
|
||||
{
|
||||
amount: Billing.CHARGE_AMOUNT + Billing.CHARGE_FEE,
|
||||
currency: "usd",
|
||||
customer: customerID!,
|
||||
payment_method: paymentMethodID!,
|
||||
off_session: true,
|
||||
confirm: true,
|
||||
},
|
||||
{ idempotencyKey: paymentID },
|
||||
)
|
||||
|
||||
if (charge.status !== "succeeded") throw new Error(charge.last_payment_error?.message)
|
||||
const draft = await Billing.stripe().invoices.create({
|
||||
customer: customerID!,
|
||||
auto_advance: false,
|
||||
default_payment_method: paymentMethodID!,
|
||||
collection_method: "charge_automatically",
|
||||
currency: "usd",
|
||||
})
|
||||
await Billing.stripe().invoiceItems.create({
|
||||
amount: Billing.CHARGE_AMOUNT,
|
||||
currency: "usd",
|
||||
customer: customerID!,
|
||||
description: CHARGE_NAME,
|
||||
invoice: draft.id!,
|
||||
})
|
||||
await Billing.stripe().invoiceItems.create({
|
||||
amount: Billing.CHARGE_FEE,
|
||||
currency: "usd",
|
||||
customer: customerID!,
|
||||
description: CHARGE_FEE_NAME,
|
||||
invoice: draft.id!,
|
||||
})
|
||||
await Billing.stripe().invoices.finalizeInvoice(draft.id!)
|
||||
invoice = await Billing.stripe().invoices.pay(draft.id!, {
|
||||
off_session: true,
|
||||
payment_method: paymentMethodID!,
|
||||
expand: ["payments"],
|
||||
})
|
||||
if (invoice.status !== "paid" || invoice.payments?.data.length !== 1)
|
||||
throw new Error(invoice.last_finalization_error?.message)
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
@@ -114,7 +133,8 @@ export namespace Billing {
|
||||
workspaceID: Actor.workspace(),
|
||||
id: paymentID,
|
||||
amount: centsToMicroCents(CHARGE_AMOUNT),
|
||||
paymentID: charge.id,
|
||||
invoiceID: invoice.id!,
|
||||
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
|
||||
customerID,
|
||||
})
|
||||
})
|
||||
@@ -155,12 +175,13 @@ export namespace Billing {
|
||||
const customer = await Billing.get()
|
||||
const session = await Billing.stripe().checkout.sessions.create({
|
||||
mode: "payment",
|
||||
billing_address_collection: "required",
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product_data: {
|
||||
name: "opencode credits",
|
||||
name: CHARGE_NAME,
|
||||
},
|
||||
unit_amount: CHARGE_AMOUNT,
|
||||
},
|
||||
@@ -170,16 +191,13 @@ export namespace Billing {
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product_data: {
|
||||
name: "processing fee",
|
||||
name: CHARGE_FEE_NAME,
|
||||
},
|
||||
unit_amount: CHARGE_FEE,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
payment_intent_data: {
|
||||
setup_future_usage: "on_session",
|
||||
},
|
||||
...(customer.customerID
|
||||
? {
|
||||
customer: customer.customerID,
|
||||
@@ -192,6 +210,12 @@ export namespace Billing {
|
||||
workspaceID: Actor.workspace(),
|
||||
},
|
||||
currency: "usd",
|
||||
invoice_creation: {
|
||||
enabled: true,
|
||||
},
|
||||
payment_intent_data: {
|
||||
setup_future_usage: "on_session",
|
||||
},
|
||||
payment_method_types: ["card"],
|
||||
payment_method_data: {
|
||||
allow_redisplay: "always",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export namespace ZenModel {
|
||||
const ModelCostSchema = z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cacheRead: z.number().optional(),
|
||||
cacheWrite5m: z.number().optional(),
|
||||
cacheWrite1h: z.number().optional(),
|
||||
})
|
||||
|
||||
export const ModelSchema = z.object({
|
||||
cost: ModelCostSchema,
|
||||
cost200K: ModelCostSchema.optional(),
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
providers: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
api: z.string(),
|
||||
apiKey: z.string(),
|
||||
model: z.string(),
|
||||
weight: z.number().optional(),
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const ModelsSchema = z.record(z.string(), ModelSchema)
|
||||
}
|
||||
@@ -28,8 +28,10 @@ export const PaymentTable = mysqlTable(
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
customerID: varchar("customer_id", { length: 255 }),
|
||||
invoiceID: varchar("invoice_id", { length: 255 }),
|
||||
paymentID: varchar("payment_id", { length: 255 }),
|
||||
amount: bigint("amount", { mode: "number" }).notNull(),
|
||||
timeRefunded: utc("time_refunded"),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table)],
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "0.10.5",
|
||||
"version": "0.12.0",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -9,7 +9,12 @@ export default {
|
||||
if (event.event.request.method !== "POST") continue
|
||||
|
||||
const url = new URL(event.event.request.url)
|
||||
if (url.pathname !== "/zen/v1/chat/completions") return
|
||||
if (
|
||||
url.pathname !== "/zen/v1/chat/completions" &&
|
||||
url.pathname !== "/zen/v1/messages" &&
|
||||
url.pathname !== "/zen/v1/responses"
|
||||
)
|
||||
return
|
||||
|
||||
let metrics = {
|
||||
event_type: "completions",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-scripts",
|
||||
"version": "0.10.5",
|
||||
"version": "0.12.0",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "0.10.5",
|
||||
"version": "0.12.0",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -234,16 +234,10 @@ 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 })
|
||||
})
|
||||
@@ -276,16 +270,10 @@ 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) {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.10.5",
|
||||
"version": "0.12.0",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "bun run --conditions=development --conditions=browser ./src/index.ts",
|
||||
"build": "./script/build.ts"
|
||||
"build": "./script/build.ts",
|
||||
"dev": "bun run --conditions=development ./src/index.ts"
|
||||
},
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode"
|
||||
@@ -18,7 +18,6 @@
|
||||
"devDependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "2.2.10",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opentui/core-win32-x64": "0.1.24",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@tsconfig/bun": "1.0.7",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -33,18 +32,15 @@
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.15.1",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@openauthjs/openauth": "0.4.3",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opentui/core": "0.1.24",
|
||||
"@opentui/solid": "0.1.24",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"chokidar": "4.0.3",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "8.0.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "1.0.7",
|
||||
@@ -52,15 +48,11 @@
|
||||
"jsonc-parser": "3.3.1",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter": "0.22.4",
|
||||
"tree-sitter-bash": "0.23.3",
|
||||
"tree-sitter-highlight": "1.0.1",
|
||||
"tree-sitter-typescript": "0.23.2",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"ulid": "3.0.1",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.22.6",
|
||||
"xdg-basedir": "5.1.0",
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import solidPlugin from "../../../node_modules/@opentui/solid/scripts/solid-plugin"
|
||||
|
||||
const dir = new URL("..", import.meta.url).pathname
|
||||
process.chdir(dir)
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
|
||||
import pkg from "../package.json"
|
||||
|
||||
@@ -33,17 +29,10 @@ 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=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd(
|
||||
"../tui",
|
||||
)
|
||||
const opentui = `@opentui/core-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}`
|
||||
await $`mkdir -p ../../node_modules/${opentui}`
|
||||
await $`npm pack npm pack ${opentui}`.cwd(path.join(dir, "../../node_modules"))
|
||||
await $`tar -xf ../../node_modules/${opentui.replace("@opentui/", "opentui-")}-*.tgz -C ../../node_modules/${opentui} --strip-components=1`
|
||||
await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`
|
||||
.cwd("../tui")
|
||||
.quiet()
|
||||
await Bun.build({
|
||||
conditions: ["browser"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [solidPlugin],
|
||||
compile: {
|
||||
target: `bun-${os}-${arch}` as any,
|
||||
outfile: `dist/${name}/bin/opencode`,
|
||||
|
||||
@@ -7,9 +7,12 @@ import pkg from "../package.json"
|
||||
|
||||
const snapshot = process.env["OPENCODE_SNAPSHOT"] === "true"
|
||||
let version = process.env["OPENCODE_VERSION"]
|
||||
if (!version && snapshot) version = `0.0.0-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}`
|
||||
const tag = process.env["OPENCODE_TAG"] ?? (snapshot ? "snapshot" : "latest")
|
||||
if (!version && snapshot) {
|
||||
version = `0.0.0-${tag}-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}`
|
||||
process.env["OPENCODE_VERSION"] = version
|
||||
}
|
||||
if (!version) throw new Error("OPENCODE_VERSION is required")
|
||||
const npmTag = snapshot ? "snapshot" : "latest"
|
||||
|
||||
console.log(`publishing ${version}`)
|
||||
|
||||
@@ -43,9 +46,9 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
),
|
||||
)
|
||||
for (const [name] of Object.entries(binaries)) {
|
||||
await $`cd dist/${name} && chmod 777 -R . && bun publish --access public --tag ${npmTag}`
|
||||
await $`cd dist/${name} && chmod 777 -R . && bun publish --access public --tag ${tag}`
|
||||
}
|
||||
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
|
||||
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${tag}`
|
||||
|
||||
if (!snapshot) {
|
||||
for (const key of Object.keys(binaries)) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import z from "zod/v4"
|
||||
import { Auth } from "./index"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace AuthGithubCopilot {
|
||||
const CLIENT_ID = "Iv1.b507a08c87ecfe98"
|
||||
const DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||
const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"
|
||||
|
||||
interface DeviceCodeResponse {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
interface AccessTokenResponse {
|
||||
access_token?: string
|
||||
error?: string
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
interface CopilotTokenResponse {
|
||||
token: string
|
||||
expires_at: number
|
||||
refresh_in: number
|
||||
endpoints: {
|
||||
api: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function authorize() {
|
||||
const deviceResponse = await fetch(DEVICE_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CLIENT_ID,
|
||||
scope: "read:user",
|
||||
}),
|
||||
})
|
||||
const deviceData: DeviceCodeResponse = await deviceResponse.json()
|
||||
return {
|
||||
device: deviceData.device_code,
|
||||
user: deviceData.user_code,
|
||||
verification: deviceData.verification_uri,
|
||||
interval: deviceData.interval || 5,
|
||||
expiry: deviceData.expires_in,
|
||||
}
|
||||
}
|
||||
|
||||
export async function poll(device_code: string) {
|
||||
const response = await fetch(ACCESS_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CLIENT_ID,
|
||||
device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) return "failed"
|
||||
|
||||
const data: AccessTokenResponse = await response.json()
|
||||
|
||||
if (data.access_token) {
|
||||
// Store the GitHub OAuth token
|
||||
await Auth.set("github-copilot", {
|
||||
type: "oauth",
|
||||
refresh: data.access_token,
|
||||
access: "",
|
||||
expires: 0,
|
||||
})
|
||||
return "complete"
|
||||
}
|
||||
|
||||
if (data.error === "authorization_pending") return "pending"
|
||||
|
||||
if (data.error) return "failed"
|
||||
|
||||
return "pending"
|
||||
}
|
||||
|
||||
export async function access() {
|
||||
const info = await Auth.get("github-copilot")
|
||||
if (!info || info.type !== "oauth") return
|
||||
if (info.access && info.expires > Date.now()) return info.access
|
||||
|
||||
// Get new Copilot API token
|
||||
const response = await fetch(COPILOT_API_KEY_URL, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${info.refresh}`,
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
"Editor-Version": "vscode/1.99.3",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
const tokenData: CopilotTokenResponse = await response.json()
|
||||
|
||||
// Store the Copilot API token
|
||||
await Auth.set("github-copilot", {
|
||||
type: "oauth",
|
||||
refresh: info.refresh,
|
||||
access: tokenData.token,
|
||||
expires: tokenData.expires_at * 1000,
|
||||
})
|
||||
|
||||
return tokenData.token
|
||||
}
|
||||
|
||||
export const DeviceCodeError = NamedError.create("DeviceCodeError", z.object({}))
|
||||
|
||||
export const TokenExchangeError = NamedError.create(
|
||||
"TokenExchangeError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const AuthenticationError = NamedError.create(
|
||||
"AuthenticationError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const CopilotTokenError = NamedError.create(
|
||||
"CopilotTokenError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -74,10 +74,7 @@ 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,
|
||||
|
||||
@@ -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() as any)
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Bun.spawn({
|
||||
cmd: wellknown.auth.command,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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"
|
||||
@@ -6,7 +7,6 @@ import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { $ } from "bun"
|
||||
|
||||
const WORKFLOW_FILE = ".github/workflows/opencode.yml"
|
||||
|
||||
@@ -196,7 +196,7 @@ export const GithubInstallCommand = cmd({
|
||||
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data: any) => data.installation)
|
||||
.then((data) => data.installation)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Theme } from "../context/theme"
|
||||
|
||||
export const SplitBorder = {
|
||||
border: ["left" as const, "right" as const],
|
||||
borderColor: Theme.border,
|
||||
customBorderChars: {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "┃",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: "",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
},
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogModel } from "./dialog-model"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
import { DialogSessionList } from "./dialog-session-list"
|
||||
|
||||
export function DialogCommand() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Commands"
|
||||
options={[
|
||||
{
|
||||
title: "Switch model",
|
||||
value: "switch-model",
|
||||
category: "Agent",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogModel />)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Switch session",
|
||||
value: "switch-session",
|
||||
category: "Session",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogSessionList />)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "New session",
|
||||
value: "new-session",
|
||||
category: "Session",
|
||||
onSelect: () => {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Share session",
|
||||
value: "share-session",
|
||||
category: "Session",
|
||||
onSelect: () => {
|
||||
console.log("share session")
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useLocal } from "../context/local"
|
||||
import { useSync } from "../context/sync"
|
||||
import { map, pipe, flatMap, entries, filter, isDeepEqual } from "remeda"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
|
||||
export function DialogModel() {
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() => [
|
||||
...local.model.recent().map((item) => {
|
||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)!
|
||||
const model = provider.models[item.modelID]
|
||||
return {
|
||||
key: item,
|
||||
value: {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
title: model.name ?? item.modelID,
|
||||
description: provider.name,
|
||||
category: "Recent",
|
||||
}
|
||||
}),
|
||||
...pipe(
|
||||
sync.data.provider,
|
||||
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) => !local.model.recent().find((y) => isDeepEqual(y, x.value))),
|
||||
),
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Select model"
|
||||
current={local.model.current()}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
local.model.set(option.value, { recent: true })
|
||||
dialog.clear()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useSync } from "../context/sync"
|
||||
import { createMemo, onMount } from "solid-js"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const route = useRoute()
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return sync.data.session.map((x) => {
|
||||
let category = new Date(x.time.created).toDateString()
|
||||
if (category === today) {
|
||||
category = "Today"
|
||||
}
|
||||
return {
|
||||
title: x.title,
|
||||
value: x.id,
|
||||
category,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Sessions"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../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.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()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
import { InputRenderable, TextAttributes, BoxRenderable, type ParsedKey } from "@opentui/core"
|
||||
import { createEffect, createMemo, createResource, For, Match, onMount, Switch } from "solid-js"
|
||||
|
||||
import { useLocal } from "../context/local"
|
||||
import { Theme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { SplitBorder } from "./border"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Identifier } from "../../../../id/id"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import type { FilePart } from "@opencode-ai/sdk"
|
||||
import { Instance } from "../../../../project/instance"
|
||||
import fuzzysort from "fuzzysort"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
}
|
||||
|
||||
type Prompt = {
|
||||
input: string
|
||||
parts: Omit<FilePart, "id" | "messageID" | "sessionID">[]
|
||||
}
|
||||
|
||||
export function Prompt(props: PromptProps) {
|
||||
let input: InputRenderable
|
||||
let anchor: BoxRenderable
|
||||
let autocomplete: AutocompleteRef
|
||||
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const sdk = useSDK()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
|
||||
const [store, setStore] = createStore<Prompt>({
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
|
||||
const messages = createMemo(() => {
|
||||
if (!props.sessionID) return []
|
||||
return sync.data.message[props.sessionID] ?? []
|
||||
})
|
||||
const working = createMemo(() => {
|
||||
const last = messages()[messages().length - 1]
|
||||
if (!last) return false
|
||||
if (last.role === "user") return true
|
||||
return !last.time.completed
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (dialog.stack.length === 0 && input) input.focus()
|
||||
if (dialog.stack.length > 0) input.blur()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Autocomplete
|
||||
ref={(r) => (autocomplete = r)}
|
||||
anchor={() => anchor}
|
||||
input={() => input}
|
||||
setPrompt={(cb) => {
|
||||
setStore(produce(cb))
|
||||
input.cursorPosition = store.input.length
|
||||
}}
|
||||
value={store.input}
|
||||
/>
|
||||
<box ref={(r) => (anchor = r)}>
|
||||
<box flexDirection="row" {...SplitBorder}>
|
||||
<box backgroundColor={Theme.backgroundElement} width={3} justifyContent="center" alignItems="center">
|
||||
<text attributes={TextAttributes.BOLD} fg={Theme.primary}>
|
||||
{">"}
|
||||
</text>
|
||||
</box>
|
||||
<box paddingTop={1} paddingBottom={2} backgroundColor={Theme.backgroundElement} flexGrow={1}>
|
||||
<input
|
||||
onInput={(value) => {
|
||||
let diff = value.length - store.input.length
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.input = value
|
||||
for (let i = 0; i < draft.parts.length; i++) {
|
||||
const part = draft.parts[i]
|
||||
if (!part.source) continue
|
||||
if (part.source.text.start >= input.cursorPosition) {
|
||||
part.source.text.start += diff
|
||||
part.source.text.end += diff
|
||||
}
|
||||
const sliced = draft.input.slice(part.source.text.start, part.source.text.end)
|
||||
if (sliced != part.source.text.value && diff < 0) {
|
||||
diff -= part.source.text.value.length
|
||||
draft.input =
|
||||
draft.input.slice(0, part.source.text.start) + draft.input.slice(part.source.text.end)
|
||||
draft.parts.splice(i, 1)
|
||||
input.cursorPosition = Math.max(0, part.source.text.start - 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
autocomplete.onInput(value)
|
||||
}}
|
||||
value={store.input}
|
||||
onKeyDown={(e) => {
|
||||
autocomplete.onKeyDown(e)
|
||||
const old = input.cursorPosition
|
||||
setTimeout(() => {
|
||||
const position = input.cursorPosition
|
||||
const direction = Math.sign(old - position)
|
||||
for (const part of store.parts) {
|
||||
if (part.source && part.source.type === "file") {
|
||||
if (position >= part.source.text.start && position < part.source.text.end) {
|
||||
if (direction === 1) {
|
||||
input.cursorPosition = Math.max(0, part.source.text.start - 1)
|
||||
}
|
||||
if (direction === -1) {
|
||||
input.cursorPosition = part.source.text.end
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
onSubmit={async () => {
|
||||
if (autocomplete.visible) return
|
||||
if (!store.input) return
|
||||
const sessionID = props.sessionID
|
||||
? props.sessionID
|
||||
: await (async () => {
|
||||
const sessionID = await sdk.session.create({}).then((x) => x.data!.id)
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
return sessionID
|
||||
})()
|
||||
const messageID = Identifier.ascending("message")
|
||||
const input = store.input
|
||||
const parts = store.parts
|
||||
setStore({
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
await sdk.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: input,
|
||||
},
|
||||
...parts.map((x) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
...x,
|
||||
})),
|
||||
],
|
||||
},
|
||||
})
|
||||
}}
|
||||
ref={(r) => (input = r)}
|
||||
onMouseDown={(r) => r.target?.focus()}
|
||||
focusedBackgroundColor={Theme.backgroundElement}
|
||||
cursorColor={Theme.primary}
|
||||
backgroundColor={Theme.backgroundElement}
|
||||
/>
|
||||
</box>
|
||||
<box backgroundColor={Theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={1} flexDirection="row" justifyContent="space-between">
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
<text>working...</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text>
|
||||
enter <span style={{ fg: Theme.textMuted }}>send</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text>
|
||||
<span style={{ fg: Theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
|
||||
<span style={{ bold: true }}>{local.model.parsed().model}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
onKeyDown: (e: ParsedKey) => void
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
function Autocomplete(props: {
|
||||
value: string
|
||||
setPrompt: (input: (prompt: Prompt) => void) => void
|
||||
anchor: () => BoxRenderable
|
||||
input: () => InputRenderable
|
||||
ref: (ref: AutocompleteRef) => void
|
||||
}) {
|
||||
const sdk = useSDK()
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
selected: 0,
|
||||
visible: false,
|
||||
position: { x: 0, y: 0, width: 0 },
|
||||
})
|
||||
const filter = createMemo(() => {
|
||||
if (!store.visible) return ""
|
||||
return props.value.substring(store.index + 1)
|
||||
})
|
||||
|
||||
const [files] = createResource(
|
||||
() => [filter()],
|
||||
async () => {
|
||||
if (!store.visible) return []
|
||||
const result = await sdk.find.files({
|
||||
query: {
|
||||
query: filter(),
|
||||
},
|
||||
})
|
||||
if (result.error) return []
|
||||
return result.data ?? []
|
||||
},
|
||||
{
|
||||
initialValue: [],
|
||||
},
|
||||
)
|
||||
|
||||
const options = createMemo(() => {
|
||||
const mixed = [...files().map((x) => ({ type: "file", value: x }))]
|
||||
const result = fuzzysort.go(filter(), mixed, {
|
||||
keys: ["value"],
|
||||
})
|
||||
return result.map((arr) => arr.obj)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
filter()
|
||||
setStore("selected", 0)
|
||||
})
|
||||
|
||||
function move(direction: -1 | 1) {
|
||||
if (!store.visible) return
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = files().length - 1
|
||||
if (next >= files().length) next = 0
|
||||
setStore("selected", next)
|
||||
}
|
||||
|
||||
function show() {
|
||||
setStore({
|
||||
visible: true,
|
||||
index: props.input().cursorPosition,
|
||||
position: {
|
||||
x: props.anchor().x,
|
||||
y: props.anchor().y,
|
||||
width: props.anchor().width,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setStore("visible", false)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
props.ref({
|
||||
get visible() {
|
||||
return store.visible
|
||||
},
|
||||
onInput(value: string) {
|
||||
if (value.length <= store.index) hide()
|
||||
},
|
||||
onKeyDown(e: ParsedKey) {
|
||||
if (store.visible) {
|
||||
if (e.name === "up") move(-1)
|
||||
if (e.name === "down") move(1)
|
||||
if (e.name === "escape") hide()
|
||||
if (e.name === "return") {
|
||||
const file = files()[store.selected]
|
||||
if (!file) return
|
||||
const part: Prompt["parts"][number] = {
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: file,
|
||||
url: `file://${Instance.directory}/${file}`,
|
||||
source: {
|
||||
type: "file",
|
||||
text: {
|
||||
start: store.index,
|
||||
end: store.index + file.length + 1,
|
||||
value: "@" + file,
|
||||
},
|
||||
path: file,
|
||||
},
|
||||
}
|
||||
props.setPrompt((draft) => {
|
||||
const append = "@" + file + " "
|
||||
if (store.index === 0) draft.input = append
|
||||
if (store.index > 0) draft.input = draft.input.slice(0, store.index) + append
|
||||
draft.parts.push(part)
|
||||
})
|
||||
setTimeout(() => hide(), 0)
|
||||
}
|
||||
}
|
||||
if (!store.visible && e.name === "@") {
|
||||
const last = props.value.at(-1)
|
||||
if (last === " " || last === undefined) {
|
||||
show()
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
return (
|
||||
<box
|
||||
visible={store.visible}
|
||||
position="absolute"
|
||||
top={store.position.y - 10}
|
||||
left={store.position.x}
|
||||
width={store.position.width}
|
||||
zIndex={100}
|
||||
{...SplitBorder}
|
||||
>
|
||||
<box backgroundColor={Theme.backgroundElement} height={10}>
|
||||
<For each={options()}>
|
||||
{(option, index) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index() === store.selected ? Theme.primary : undefined}
|
||||
>
|
||||
<text fg={index() === store.selected ? Theme.background : Theme.text}>{option.value}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
|
||||
import { useSync } from "./sync"
|
||||
import { Theme } from "./theme"
|
||||
import { uniqueBy } from "remeda"
|
||||
import path from "path"
|
||||
import { Global } from "../../../../global"
|
||||
|
||||
function init() {
|
||||
const sync = useSync()
|
||||
|
||||
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
|
||||
const agent = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
current: string
|
||||
}>({
|
||||
current: agents()[0].name,
|
||||
})
|
||||
return {
|
||||
current() {
|
||||
return agents().find((x) => x.name === store.current)!
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
let next = agents().findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = agents().length - 1
|
||||
if (next >= agents().length) next = 0
|
||||
const value = agents()[next]
|
||||
setStore("current", value.name)
|
||||
if (value.model)
|
||||
model.set({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
},
|
||||
color(name: string) {
|
||||
const index = agents().findIndex((x) => x.name === name)
|
||||
const colors = [Theme.secondary, Theme.accent, Theme.success, Theme.warning, Theme.primary, Theme.error]
|
||||
return colors[index % colors.length]
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const model = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
model: Record<
|
||||
string,
|
||||
{
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
>
|
||||
recent: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
}>({
|
||||
model: {},
|
||||
recent: [],
|
||||
})
|
||||
|
||||
const file = Bun.file(path.join(Global.Path.state, "model.json"))
|
||||
|
||||
file
|
||||
.json()
|
||||
.then((x) => {
|
||||
setStore("recent", x.recent)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
createEffect(() => {
|
||||
Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
recent: store.recent,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const fallback = createMemo(() => {
|
||||
if (store.recent.length) return store.recent[0]
|
||||
const provider = sync.data.provider[0]
|
||||
const model = Object.values(provider.models)[0]
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
}
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
const a = agent.current()
|
||||
return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
|
||||
})
|
||||
|
||||
return {
|
||||
current,
|
||||
recent() {
|
||||
return store.recent
|
||||
},
|
||||
parsed: createMemo(() => {
|
||||
const value = current()
|
||||
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,
|
||||
}
|
||||
}),
|
||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("model", agent.current().name, model)
|
||||
if (options?.recent) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
setStore("recent", uniq)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const result = {
|
||||
model,
|
||||
agent,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type LocalContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<LocalContext>()
|
||||
|
||||
export function LocalProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useLocal() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useLocal must be used within a LocalProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
type Route =
|
||||
| {
|
||||
type: "home"
|
||||
}
|
||||
| {
|
||||
type: "session"
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
function init() {
|
||||
const [store, setStore] = createStore<Route>(
|
||||
process.env["OPENCODE_ROUTE"]
|
||||
? JSON.parse(process.env["OPENCODE_ROUTE"])
|
||||
: {
|
||||
type: "home",
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
get data() {
|
||||
return store
|
||||
},
|
||||
navigate(route: Route) {
|
||||
console.log("navigate", route)
|
||||
setStore(route)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type RouteContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<RouteContext>()
|
||||
|
||||
export function RouteProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
// @ts-ignore
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useRoute() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useRoute must be used within a RouteProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function useRouteData<T extends Route["type"]>(type: T) {
|
||||
const route = useRoute()
|
||||
return route.data as Extract<Route, { type: typeof type }>
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Server } from "../../../../server/server"
|
||||
|
||||
function init() {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
// @ts-ignore
|
||||
fetch: async (a) => {
|
||||
// @ts-ignore
|
||||
return Server.App().fetch(a)
|
||||
},
|
||||
})
|
||||
return client
|
||||
}
|
||||
|
||||
type SDKContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<SDKContext>()
|
||||
|
||||
export function SDKProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useSDK() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useSDK must be used within a SDKProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { Message, Agent, Provider, Session, Part, Config, Todo } from "@opencode-ai/sdk"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useSDK } from "./sdk"
|
||||
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
||||
import { Binary } from "../../../../util/binary"
|
||||
|
||||
function init() {
|
||||
const [store, setStore] = createStore<{
|
||||
ready: boolean
|
||||
provider: Provider[]
|
||||
agent: Agent[]
|
||||
config: Config
|
||||
session: Session[]
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
}>({
|
||||
config: {},
|
||||
ready: false,
|
||||
agent: [],
|
||||
provider: [],
|
||||
session: [],
|
||||
todo: {},
|
||||
message: {},
|
||||
part: {},
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
switch (event.type) {
|
||||
case "todo.updated":
|
||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
||||
break
|
||||
case "session.updated":
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
case "message.updated": {
|
||||
const messages = store.message[event.properties.info.sessionID]
|
||||
if (!messages) {
|
||||
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.part)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Promise.all([
|
||||
sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
|
||||
sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
sdk.session.list().then((x) => setStore("session", x.data ?? [])),
|
||||
sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
]).then(() => setStore("ready", true))
|
||||
|
||||
return {
|
||||
data: store,
|
||||
set: setStore,
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
const [session, messages, todo] = await Promise.all([
|
||||
sdk.session.get({ path: { id: sessionID } }),
|
||||
sdk.session.messages({ path: { id: sessionID } }),
|
||||
sdk.session.todo({ path: { id: sessionID } }),
|
||||
])
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
draft.session[match.index] = session.data!
|
||||
draft.todo[sessionID] = todo.data ?? []
|
||||
draft.message[sessionID] = messages.data!.map((x) => x.info)
|
||||
for (const message of messages.data!) {
|
||||
draft.part[message.info.id] = message.parts
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type SyncContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<SyncContext>()
|
||||
|
||||
export function SyncProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return (
|
||||
<Show when={value.data.ready}>
|
||||
<ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSync() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useSync must be used within a SyncProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
const OPENCODE_THEME = {
|
||||
primary: {
|
||||
dark: "#fab283",
|
||||
light: "#3b7dd8",
|
||||
},
|
||||
secondary: {
|
||||
dark: "#5c9cf5",
|
||||
light: "#7b5bb6",
|
||||
},
|
||||
accent: {
|
||||
dark: "#9d7cd8",
|
||||
light: "#d68c27",
|
||||
},
|
||||
error: {
|
||||
dark: "#e06c75",
|
||||
light: "#d1383d",
|
||||
},
|
||||
warning: {
|
||||
dark: "#f5a742",
|
||||
light: "#d68c27",
|
||||
},
|
||||
success: {
|
||||
dark: "#7fd88f",
|
||||
light: "#3d9a57",
|
||||
},
|
||||
info: {
|
||||
dark: "#56b6c2",
|
||||
light: "#318795",
|
||||
},
|
||||
text: {
|
||||
dark: "#eeeeee",
|
||||
light: "#1a1a1a",
|
||||
},
|
||||
textMuted: {
|
||||
dark: "#808080",
|
||||
light: "#8a8a8a",
|
||||
},
|
||||
background: {
|
||||
dark: "#0a0a0a",
|
||||
light: "#ffffff",
|
||||
},
|
||||
backgroundPanel: {
|
||||
dark: "#141414",
|
||||
light: "#fafafa",
|
||||
},
|
||||
backgroundElement: {
|
||||
dark: "#1e1e1e",
|
||||
light: "#f5f5f5",
|
||||
},
|
||||
border: {
|
||||
dark: "#484848",
|
||||
light: "#b8b8b8",
|
||||
},
|
||||
borderActive: {
|
||||
dark: "#606060",
|
||||
light: "#a0a0a0",
|
||||
},
|
||||
borderSubtle: {
|
||||
dark: "#3c3c3c",
|
||||
light: "#d4d4d4",
|
||||
},
|
||||
diffAdded: {
|
||||
dark: "#4fd6be",
|
||||
light: "#1e725c",
|
||||
},
|
||||
diffRemoved: {
|
||||
dark: "#c53b53",
|
||||
light: "#c53b53",
|
||||
},
|
||||
diffContext: {
|
||||
dark: "#828bb8",
|
||||
light: "#7086b5",
|
||||
},
|
||||
diffHunkHeader: {
|
||||
dark: "#828bb8",
|
||||
light: "#7086b5",
|
||||
},
|
||||
diffHighlightAdded: {
|
||||
dark: "#b8db87",
|
||||
light: "#4db380",
|
||||
},
|
||||
diffHighlightRemoved: {
|
||||
dark: "#e26a75",
|
||||
light: "#f52a65",
|
||||
},
|
||||
diffAddedBg: {
|
||||
dark: "#20303b",
|
||||
light: "#d5e5d5",
|
||||
},
|
||||
diffRemovedBg: {
|
||||
dark: "#37222c",
|
||||
light: "#f7d8db",
|
||||
},
|
||||
diffContextBg: {
|
||||
dark: "#141414",
|
||||
light: "#fafafa",
|
||||
},
|
||||
diffLineNumber: {
|
||||
dark: "#1e1e1e",
|
||||
light: "#f5f5f5",
|
||||
},
|
||||
diffAddedLineNumberBg: {
|
||||
dark: "#1b2b34",
|
||||
light: "#c5d5c5",
|
||||
},
|
||||
diffRemovedLineNumberBg: {
|
||||
dark: "#2d1f26",
|
||||
light: "#e7c8cb",
|
||||
},
|
||||
markdownText: {
|
||||
dark: "#eeeeee",
|
||||
light: "#1a1a1a",
|
||||
},
|
||||
markdownHeading: {
|
||||
dark: "#9d7cd8",
|
||||
light: "#d68c27",
|
||||
},
|
||||
markdownLink: {
|
||||
dark: "#fab283",
|
||||
light: "#3b7dd8",
|
||||
},
|
||||
markdownLinkText: {
|
||||
dark: "#56b6c2",
|
||||
light: "#318795",
|
||||
},
|
||||
markdownCode: {
|
||||
dark: "#7fd88f",
|
||||
light: "#3d9a57",
|
||||
},
|
||||
markdownBlockQuote: {
|
||||
dark: "#e5c07b",
|
||||
light: "#b0851f",
|
||||
},
|
||||
markdownEmph: {
|
||||
dark: "#e5c07b",
|
||||
light: "#b0851f",
|
||||
},
|
||||
markdownStrong: {
|
||||
dark: "#f5a742",
|
||||
light: "#d68c27",
|
||||
},
|
||||
markdownHorizontalRule: {
|
||||
dark: "#808080",
|
||||
light: "#8a8a8a",
|
||||
},
|
||||
markdownListItem: {
|
||||
dark: "#fab283",
|
||||
light: "#3b7dd8",
|
||||
},
|
||||
markdownListEnumeration: {
|
||||
dark: "#56b6c2",
|
||||
light: "#318795",
|
||||
},
|
||||
markdownImage: {
|
||||
dark: "#fab283",
|
||||
light: "#3b7dd8",
|
||||
},
|
||||
markdownImageText: {
|
||||
dark: "#56b6c2",
|
||||
light: "#318795",
|
||||
},
|
||||
markdownCodeBlock: {
|
||||
dark: "#eeeeee",
|
||||
light: "#1a1a1a",
|
||||
},
|
||||
syntaxComment: {
|
||||
dark: "#808080",
|
||||
light: "#8a8a8a",
|
||||
},
|
||||
syntaxKeyword: {
|
||||
dark: "#9d7cd8",
|
||||
light: "#d68c27",
|
||||
},
|
||||
syntaxFunction: {
|
||||
dark: "#fab283",
|
||||
light: "#3b7dd8",
|
||||
},
|
||||
syntaxVariable: {
|
||||
dark: "#e06c75",
|
||||
light: "#d1383d",
|
||||
},
|
||||
syntaxString: {
|
||||
dark: "#7fd88f",
|
||||
light: "#3d9a57",
|
||||
},
|
||||
syntaxNumber: {
|
||||
dark: "#f5a742",
|
||||
light: "#d68c27",
|
||||
},
|
||||
syntaxType: {
|
||||
dark: "#e5c07b",
|
||||
light: "#b0851f",
|
||||
},
|
||||
syntaxOperator: {
|
||||
dark: "#56b6c2",
|
||||
light: "#318795",
|
||||
},
|
||||
syntaxPunctuation: {
|
||||
dark: "#eeeeee",
|
||||
light: "#1a1a1a",
|
||||
},
|
||||
} as const
|
||||
|
||||
type Theme = {
|
||||
primary: string
|
||||
secondary: string
|
||||
accent: string
|
||||
error: string
|
||||
warning: string
|
||||
success: string
|
||||
info: string
|
||||
text: string
|
||||
textMuted: string
|
||||
background: string
|
||||
backgroundPanel: string
|
||||
backgroundElement: string
|
||||
border: string
|
||||
borderActive: string
|
||||
borderSubtle: string
|
||||
diffAdded: string
|
||||
diffRemoved: string
|
||||
diffContext: string
|
||||
diffHunkHeader: string
|
||||
diffHighlightAdded: string
|
||||
diffHighlightRemoved: string
|
||||
diffAddedBg: string
|
||||
diffRemovedBg: string
|
||||
diffContextBg: string
|
||||
diffLineNumber: string
|
||||
diffAddedLineNumberBg: string
|
||||
diffRemovedLineNumberBg: string
|
||||
markdownText: string
|
||||
markdownHeading: {}
|
||||
markdownLink: string
|
||||
markdownLinkText: string
|
||||
markdownCode: string
|
||||
markdownBlockQuote: string
|
||||
markdownEmph: string
|
||||
markdownStrong: string
|
||||
markdownHorizontalRule: string
|
||||
markdownListItem: string
|
||||
markdownListEnumeration: {}
|
||||
markdownImage: string
|
||||
markdownImageText: string
|
||||
markdownCodeBlock: string
|
||||
syntaxComment: string
|
||||
syntaxKeyword: string
|
||||
syntaxFunction: string
|
||||
syntaxVariable: string
|
||||
syntaxString: string
|
||||
syntaxNumber: string
|
||||
syntaxType: string
|
||||
syntaxOperator: string
|
||||
syntaxPunctuation: string
|
||||
}
|
||||
|
||||
export const Theme = Object.entries(OPENCODE_THEME).reduce((acc, [key, value]) => {
|
||||
acc[key as keyof Theme] = value.dark
|
||||
return acc
|
||||
}, {} as Theme)
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Installation } from "../../../installation"
|
||||
import { Theme } from "./context/theme"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Prompt } from "./component/prompt"
|
||||
|
||||
export function Home() {
|
||||
return (
|
||||
<box flexGrow={1} justifyContent="center" alignItems="center">
|
||||
<box>
|
||||
<Logo />
|
||||
<box paddingTop={2}>
|
||||
<HelpRow slash="new">new session</HelpRow>
|
||||
<HelpRow slash="help">show help</HelpRow>
|
||||
<HelpRow slash="share">share session</HelpRow>
|
||||
<HelpRow slash="models">list models</HelpRow>
|
||||
<HelpRow slash="agents">list agents</HelpRow>
|
||||
</box>
|
||||
</box>
|
||||
<box paddingTop={3} minWidth={75}>
|
||||
<Prompt />
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function HelpRow(props: { children: string; slash: string }) {
|
||||
return (
|
||||
<text>
|
||||
<span style={{ bold: true, fg: Theme.primary }}>/{props.slash.padEnd(10, " ")}</span>
|
||||
<span>{props.children.padEnd(15, " ")} </span>
|
||||
<span style={{ fg: Theme.textMuted }}>ctrl+x n</span>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function Logo() {
|
||||
return (
|
||||
<box>
|
||||
<box flexDirection="row">
|
||||
<text fg={Theme.textMuted}>{"█▀▀█ █▀▀█ █▀▀ █▀▀▄"}</text>
|
||||
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
|
||||
{" █▀▀ █▀▀█ █▀▀▄ █▀▀"}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={Theme.textMuted}>{`█░░█ █░░█ █▀▀ █░░█`}</text>
|
||||
<text fg={Theme.text}>{` █░░ █░░█ █░░█ █▀▀`}</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={Theme.textMuted}>{`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀`}</text>
|
||||
<text fg={Theme.text}>{` ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end">
|
||||
<text fg={Theme.textMuted}>{Installation.VERSION}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { cmd } from "../cmd"
|
||||
import { render, useKeyHandler, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "./context/route"
|
||||
import { Home } from "./home"
|
||||
import { Switch, Match, createEffect } from "solid-js"
|
||||
import { Theme } from "./context/theme"
|
||||
import { Installation } from "../../../installation"
|
||||
import { Global } from "../../../global"
|
||||
import { DialogProvider, useDialog } from "./ui/dialog"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { SDKProvider } from "./context/sdk"
|
||||
import { SyncProvider } from "./context/sync"
|
||||
import { LocalProvider, useLocal } from "./context/local"
|
||||
import { DialogModel } from "./component/dialog-model"
|
||||
import { DialogCommand } from "./component/dialog-command"
|
||||
import { Session } from "./session"
|
||||
|
||||
export const OpentuiCommand = cmd({
|
||||
command: "opentui",
|
||||
describe: "print hello",
|
||||
handler: async () => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
await render(
|
||||
() => (
|
||||
<RouteProvider>
|
||||
<SDKProvider>
|
||||
<SyncProvider>
|
||||
<LocalProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</LocalProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</RouteProvider>
|
||||
),
|
||||
{
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
},
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function App() {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
|
||||
useKeyHandler(async (evt) => {
|
||||
if (evt.name === "tab") {
|
||||
local.agent.move(evt.shift ? -1 : 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.ctrl && evt.name === "p") {
|
||||
dialog.replace(() => <DialogCommand />)
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.meta && evt.name === "t") {
|
||||
renderer.toggleDebugOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.meta && evt.name === "d") {
|
||||
renderer.console.toggle()
|
||||
return
|
||||
}
|
||||
if (evt.meta && evt.name === "m") {
|
||||
dialog.replace(() => <DialogModel />)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
console.log(JSON.stringify(route.data))
|
||||
})
|
||||
|
||||
return (
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={Theme.background}>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<box height={1} backgroundColor={Theme.backgroundPanel} flexDirection="row" justifyContent="space-between">
|
||||
<box flexDirection="row">
|
||||
<box flexDirection="row" backgroundColor={Theme.backgroundElement} paddingLeft={1} paddingRight={1}>
|
||||
<text fg={Theme.textMuted}>open</text>
|
||||
<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">
|
||||
<text paddingRight={1} fg={Theme.textMuted}>
|
||||
tab
|
||||
</text>
|
||||
<text fg={local.agent.color(local.agent.current().name)}>┃</text>
|
||||
<text bg={local.agent.color(local.agent.current().name)} fg={Theme.background}>
|
||||
{" "}
|
||||
<span style={{ bold: true }}>{local.agent.current().name.toUpperCase()}</span>
|
||||
<span> AGENT </span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
import { createEffect, createMemo, For, Match, Show, Switch, type Component } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import path from "path"
|
||||
import { useRouteData } from "./context/route"
|
||||
import { useSync } from "./context/sync"
|
||||
import { SplitBorder } from "./component/border"
|
||||
import { Theme } from "./context/theme"
|
||||
import { BoxRenderable, hastToStyledText, RGBA, ScrollBoxRenderable, SyntaxStyle } from "@opentui/core"
|
||||
import { Prompt } from "./component/prompt"
|
||||
import type { AssistantMessage, Part, ToolPart, UserMessage } from "@opencode-ai/sdk"
|
||||
import type { TextPart } from "ai"
|
||||
import { useLocal } from "./context/local"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import type { Tool } from "../../../tool/tool"
|
||||
import { highlightHast, Language } from "tree-sitter-highlight"
|
||||
import type { ReadTool } from "../../../tool/read"
|
||||
import type { WriteTool } from "../../../tool/write"
|
||||
import { BashTool } from "../../../tool/bash"
|
||||
import type { GlobTool } from "../../../tool/glob"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { TodoWriteTool } from "../../../tool/todo"
|
||||
import type { GrepTool } from "../../../tool/grep"
|
||||
import type { ListTool } from "../../../tool/ls"
|
||||
import type { EditTool } from "../../../tool/edit"
|
||||
import type { PatchTool } from "../../../tool/patch"
|
||||
import type { WebFetchTool } from "../../../tool/webfetch"
|
||||
import type { TaskTool } from "../../../tool/task"
|
||||
import { useKeyboard, type BoxProps, type JSX } from "@opentui/solid"
|
||||
import { useSDK } from "./context/sdk"
|
||||
|
||||
export function Session() {
|
||||
const route = useRouteData("session")
|
||||
const sync = useSync()
|
||||
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
const todo = createMemo(() => sync.data.todo[route.sessionID] ?? [])
|
||||
let scroll: ScrollBoxRenderable
|
||||
|
||||
createEffect(() => sync.session.sync(route.sessionID))
|
||||
const sdk = useSDK()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "pageup") scroll.scrollBy(-scroll.height / 2)
|
||||
if (evt.name === "pagedown") scroll.scrollBy(scroll.height / 2)
|
||||
if (evt.name === "escape")
|
||||
sdk.session.abort({
|
||||
path: {
|
||||
id: route.sessionID,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexGrow={1} maxHeight="100%">
|
||||
<Show when={session()}>
|
||||
<box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={Theme.backgroundElement}>
|
||||
<text>
|
||||
<span style={{ bold: true, fg: Theme.accent }}>#</span>{" "}
|
||||
<span style={{ bold: true }}>{session().title}</span>
|
||||
</text>
|
||||
<box flexDirection="row">
|
||||
<Switch>
|
||||
<Match when={session().share?.url}>
|
||||
<text fg={Theme.textMuted}>{session().share!.url}</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text>
|
||||
/share <span style={{ fg: Theme.textMuted }}>to create a shareable link</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox
|
||||
ref={(r: any) => (scroll = r)}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
stickyScroll={true}
|
||||
stickyStart="bottom"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<Switch>
|
||||
<Match when={message.role === "user"}>
|
||||
<UserMessage message={message as UserMessage} parts={sync.data.part[message.id] ?? []} />
|
||||
</Match>
|
||||
<Match when={message.role === "assistant"}>
|
||||
<AssistantMessage message={message as AssistantMessage} parts={sync.data.part[message.id] ?? []} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<Show when={todo().length > 0 && false}>
|
||||
<box paddingBottom={1}>
|
||||
<For each={todo()}>
|
||||
{(todo) => (
|
||||
<text style={{ fg: todo.status === "in_progress" ? Theme.success : Theme.textMuted }}>
|
||||
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<box flexShrink={0}>
|
||||
<Prompt sessionID={route.sessionID} />
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function UserMessage(props: { message: UserMessage; parts: Part[] }) {
|
||||
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
|
||||
const sync = useSync()
|
||||
return (
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
backgroundColor={Theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={Theme.secondary}
|
||||
>
|
||||
<text>{text()?.text}</text>
|
||||
<text>
|
||||
{sync.data.config.username ?? "You"}{" "}
|
||||
<span style={{ fg: Theme.textMuted }}>({Locale.time(props.message.time.created)})</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) {
|
||||
return (
|
||||
<For each={props.parts}>
|
||||
{(part) => {
|
||||
const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
|
||||
return (
|
||||
<Show when={component()}>
|
||||
<Dynamic component={component()} part={part as any} message={props.message} />
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
|
||||
const PART_MAPPING = {
|
||||
text: TextPart,
|
||||
tool: ToolPart,
|
||||
}
|
||||
function resize(el: BoxRenderable) {
|
||||
const parent = el.parent
|
||||
if (!parent) return
|
||||
if (el.height > 1) {
|
||||
el.marginTop = 1
|
||||
return
|
||||
}
|
||||
const children = parent.getChildren()
|
||||
const index = children.indexOf(el)
|
||||
const previous = children[index - 1]
|
||||
if (!previous) return
|
||||
if (previous.height > 1) {
|
||||
el.marginTop = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function TextPart(props: { part: TextPart; message: AssistantMessage }) {
|
||||
const sync = useSync()
|
||||
const agent = createMemo(() => sync.data.agent.find((x) => x.name === props.message.mode)!)
|
||||
const local = useLocal()
|
||||
|
||||
return (
|
||||
<box paddingLeft={3} marginTop={1}>
|
||||
<text>{props.part.text.trim()}</text>
|
||||
<text>
|
||||
<span style={{ fg: local.agent.color(agent().name) }}>{Locale.titlecase(agent().name)}</span>{" "}
|
||||
<span style={{ fg: Theme.textMuted }}>{props.message.providerID + "/" + props.message.modelID}</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
// Pending messages moved to individual tool pending functions
|
||||
|
||||
function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
|
||||
const component = createMemo(() => {
|
||||
const ready = ToolRegistry.ready(props.part.tool)
|
||||
if (!ready) return
|
||||
|
||||
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
|
||||
const input = props.part.state.input
|
||||
|
||||
const container: BoxProps =
|
||||
ToolRegistry.container(props.part.tool) === "block"
|
||||
? {
|
||||
border: ["left"] as const,
|
||||
paddingTop: 1,
|
||||
paddingBottom: 1,
|
||||
paddingLeft: 2,
|
||||
gap: 1,
|
||||
backgroundColor: Theme.backgroundPanel,
|
||||
customBorderChars: SplitBorder.customBorderChars,
|
||||
borderColor: Theme.background,
|
||||
}
|
||||
: {
|
||||
paddingLeft: 3,
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
{...container}
|
||||
onSizeChange={function () {
|
||||
setTimeout(() => {
|
||||
resize(this)
|
||||
}, 0)
|
||||
}}
|
||||
>
|
||||
<Dynamic
|
||||
component={ready}
|
||||
input={input}
|
||||
metadata={metadata}
|
||||
output={props.part.state.status === "completed" ? props.part.state.output : undefined}
|
||||
/>
|
||||
{props.part.state.status === "error" && (
|
||||
<box paddingLeft={2}>
|
||||
<text fg={Theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)
|
||||
})
|
||||
|
||||
return <Show when={component()}>{component()}</Show>
|
||||
}
|
||||
|
||||
type ToolProps<T extends Tool.Info> = {
|
||||
input: Partial<Tool.InferParameters<T>>
|
||||
metadata: Partial<Tool.InferMetadata<T>>
|
||||
output?: string
|
||||
}
|
||||
|
||||
const ToolRegistry = (() => {
|
||||
const state: Record<string, { name: string; container: "inline" | "block"; ready?: Component<ToolProps<any>> }> = {}
|
||||
function register<T extends Tool.Info>(input: {
|
||||
name: string
|
||||
container: "inline" | "block"
|
||||
ready?: Component<ToolProps<T>>
|
||||
}) {
|
||||
state[input.name] = input
|
||||
return input
|
||||
}
|
||||
return {
|
||||
register,
|
||||
container(name: string) {
|
||||
return state[name]?.container
|
||||
},
|
||||
ready(name: string) {
|
||||
return state[name]?.ready
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
|
||||
return (
|
||||
<text paddingLeft={3} fg={props.when ? Theme.textMuted : Theme.text}>
|
||||
<Show fallback={<>~ {props.fallback}</>} when={props.when}>
|
||||
<span style={{ bold: true }}>{props.icon}</span> {props.children}
|
||||
</Show>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
ToolRegistry.register<typeof BashTool>({
|
||||
name: "bash",
|
||||
container: "block",
|
||||
ready(props) {
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
|
||||
{props.input.description}
|
||||
</ToolTitle>
|
||||
<Show when={props.input.command}>
|
||||
<text fg={Theme.text}>$ {props.input.command}</text>
|
||||
</Show>
|
||||
<Show when={props.output?.trim()}>
|
||||
<box>
|
||||
<text fg={Theme.text}>{props.output?.trim()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const syntax = new SyntaxStyle({
|
||||
keyword: { fg: RGBA.fromHex(Theme.syntaxKeyword), bold: true },
|
||||
string: { fg: RGBA.fromHex(Theme.syntaxString) },
|
||||
comment: { fg: RGBA.fromHex(Theme.syntaxComment), italic: true },
|
||||
number: { fg: RGBA.fromHex(Theme.syntaxNumber) },
|
||||
function: { fg: RGBA.fromHex(Theme.syntaxFunction) },
|
||||
type: { fg: RGBA.fromHex(Theme.syntaxType) },
|
||||
operator: { fg: RGBA.fromHex(Theme.syntaxOperator) },
|
||||
variable: { fg: RGBA.fromHex(Theme.syntaxVariable) },
|
||||
bracket: { fg: RGBA.fromHex(Theme.syntaxPunctuation) },
|
||||
punctuation: { fg: RGBA.fromHex(Theme.syntaxPunctuation) },
|
||||
default: { fg: RGBA.fromHex(Theme.syntaxVariable) },
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof ReadTool>({
|
||||
name: "read",
|
||||
container: "inline",
|
||||
ready(props) {
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="→" fallback="Reading file..." when={props.input.filePath}>
|
||||
Read {normalizePath(props.input.filePath!)}
|
||||
</ToolTitle>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof WriteTool>({
|
||||
name: "write",
|
||||
container: "block",
|
||||
ready(props) {
|
||||
const lines = createMemo(() => {
|
||||
return props.input.content?.split("\n") ?? []
|
||||
})
|
||||
const code = createMemo(() => {
|
||||
if (!props.input.content) return ""
|
||||
const text = props.input.content
|
||||
const hast = highlightHast(text, Language.TS)
|
||||
const styled = hastToStyledText(hast as any, syntax)
|
||||
return styled
|
||||
})
|
||||
|
||||
const numbers = createMemo(() => {
|
||||
const pad = lines().length.toString().length
|
||||
return lines()
|
||||
.map((_, index) => index + 1)
|
||||
.map((x) => x.toString().padStart(pad, " "))
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="←" fallback="Preparing write..." when={props.input.filePath}>
|
||||
Wrote {props.input.filePath}
|
||||
</ToolTitle>
|
||||
<box flexDirection="row">
|
||||
<box>
|
||||
<For each={numbers()}>{(value) => <text style={{ fg: Theme.textMuted }}>{value}</text>}</For>
|
||||
</box>
|
||||
<box paddingLeft={1}>
|
||||
<text>{code()}</text>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof GlobTool>({
|
||||
name: "glob",
|
||||
container: "inline",
|
||||
ready(props) {
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}>
|
||||
Glob "{props.input.pattern}" <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
|
||||
</ToolTitle>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof GrepTool>({
|
||||
name: "grep",
|
||||
container: "inline",
|
||||
ready(props) {
|
||||
return (
|
||||
<ToolTitle icon="✱" fallback="Searching content..." when={props.input.pattern}>
|
||||
Grep "{props.input.pattern}"
|
||||
</ToolTitle>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof ListTool>({
|
||||
name: "list",
|
||||
container: "inline",
|
||||
ready(props) {
|
||||
const dir = createMemo(() => {
|
||||
if (props.input.path) {
|
||||
return normalizePath(props.input.path)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="→" fallback="Listing directory..." when={props.input.path !== undefined}>
|
||||
List {dir()}
|
||||
</ToolTitle>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof TaskTool>({
|
||||
name: "task",
|
||||
container: "block",
|
||||
ready(props) {
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="%" fallback="Delegating..." when={props.input.description}>
|
||||
Task {props.input.description}
|
||||
</ToolTitle>
|
||||
<Show when={props.metadata.summary?.length}>
|
||||
<box>
|
||||
<For each={props.metadata.summary ?? []}>
|
||||
{(task) => (
|
||||
<text style={{ fg: Theme.textMuted }}>
|
||||
∟ {task.tool} {task.state.status === "completed" ? task.state.title : ""}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof WebFetchTool>({
|
||||
name: "webfetch",
|
||||
container: "block",
|
||||
ready(props) {
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="%" fallback="Fetching from the web..." when={(props.input as any).url}>
|
||||
WebFetch {(props.input as any).url}
|
||||
</ToolTitle>
|
||||
<Show when={props.output}>
|
||||
<box>
|
||||
<text>{props.output?.trim()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof EditTool>({
|
||||
name: "edit",
|
||||
container: "block",
|
||||
ready(props) {
|
||||
const code = createMemo(() => {
|
||||
if (!props.metadata.diff) return ""
|
||||
const text = props.metadata.diff.split("\n").slice(5).join("\n")
|
||||
const hast = highlightHast(text, Language.TS)
|
||||
const styled = hastToStyledText(hast as any, syntax)
|
||||
return styled
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="←" fallback="Preparing edit..." when={props.input.filePath}>
|
||||
Edit {normalizePath(props.input.filePath!)}
|
||||
</ToolTitle>
|
||||
<Show when={code()}>
|
||||
<box paddingLeft={1}>
|
||||
<text>{code()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof PatchTool>({
|
||||
name: "patch",
|
||||
container: "block",
|
||||
ready(props) {
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="%" fallback="Preparing patch..." when={true}>
|
||||
Patch
|
||||
</ToolTitle>
|
||||
<Show when={props.output}>
|
||||
<box>
|
||||
<text>{props.output?.trim()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof TodoWriteTool>({
|
||||
name: "todowrite",
|
||||
container: "block",
|
||||
ready(props) {
|
||||
return (
|
||||
<For each={props.input.todos ?? []}>
|
||||
{(todo) => (
|
||||
<text style={{ fg: todo.status === "in_progress" ? Theme.success : Theme.textMuted }}>
|
||||
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
function normalizePath(input: string) {
|
||||
if (path.isAbsolute(input)) {
|
||||
return path.relative(Instance.directory, input) || "."
|
||||
}
|
||||
return input
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Theme } from "../context/theme"
|
||||
import { entries, flatMap, groupBy, pipe } from "remeda"
|
||||
import { batch, createEffect, createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import { isDeepEqual } from "remeda"
|
||||
|
||||
export interface DialogSelectProps<T> {
|
||||
title: string
|
||||
options: DialogSelectOption<T>[]
|
||||
onFilter?: (query: string) => void
|
||||
onSelect?: (option: DialogSelectOption<T>) => void
|
||||
current?: T
|
||||
}
|
||||
|
||||
export interface DialogSelectOption<T> {
|
||||
value: T
|
||||
title: string
|
||||
description?: string
|
||||
category?: string
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
const [store, setStore] = createStore({
|
||||
selected: 0,
|
||||
filter: "",
|
||||
})
|
||||
|
||||
let input: InputRenderable
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const needle = store.filter.toLowerCase()
|
||||
const result = pipe(
|
||||
props.options,
|
||||
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
|
||||
groupBy((x) => x.category ?? ""),
|
||||
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
|
||||
entries(),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
const flat = createMemo(() => {
|
||||
return pipe(
|
||||
grouped(),
|
||||
flatMap(([_, options]) => options),
|
||||
)
|
||||
})
|
||||
const selected = createMemo(() => flat()[store.selected])
|
||||
|
||||
createEffect(() => {
|
||||
store.filter
|
||||
setStore("selected", 0)
|
||||
scroll.scrollTo(0)
|
||||
})
|
||||
|
||||
function move(direction: -1 | 1) {
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = flat().length - 1
|
||||
if (next >= flat().length) next = 0
|
||||
setStore("selected", next)
|
||||
const target = scroll.findDescendantById(JSON.stringify(selected()?.value))
|
||||
if (!target) return
|
||||
const y = target.y - scroll.y
|
||||
if (y >= scroll.height) {
|
||||
scroll.scrollBy(y - scroll.height + 1)
|
||||
}
|
||||
if (y < 0) {
|
||||
scroll.scrollBy(y)
|
||||
if (isDeepEqual(flat()[0].value, selected()?.value)) {
|
||||
scroll.scrollTo(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "up") move(-1)
|
||||
if (evt.name === "down") move(1)
|
||||
if (evt.name === "return") {
|
||||
const option = selected()
|
||||
if (option.onSelect) option.onSelect()
|
||||
props.onSelect?.(option)
|
||||
}
|
||||
})
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
|
||||
return (
|
||||
<box gap={1}>
|
||||
<box paddingLeft={3} paddingRight={2}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
||||
<text fg={Theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box paddingTop={1} paddingBottom={1}>
|
||||
<input
|
||||
onInput={(e) => {
|
||||
batch(() => {
|
||||
setStore("filter", e)
|
||||
props.onFilter?.(e)
|
||||
})
|
||||
}}
|
||||
focusedBackgroundColor={Theme.backgroundPanel}
|
||||
cursorColor={Theme.primary}
|
||||
focusedTextColor={Theme.textMuted}
|
||||
ref={(r) => {
|
||||
input = r
|
||||
input.focus()
|
||||
}}
|
||||
placeholder="Enter search term"
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
maxHeight={10}
|
||||
>
|
||||
<For each={grouped()}>
|
||||
{([category, options], index) => (
|
||||
<box flexShrink={0}>
|
||||
<Show when={category}>
|
||||
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}>
|
||||
<text fg={Theme.accent} attributes={TextAttributes.BOLD}>
|
||||
{category}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<For each={options}>
|
||||
{(option) => {
|
||||
return (
|
||||
<Option
|
||||
id={JSON.stringify(option.value)}
|
||||
title={option.title}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={isDeepEqual(option.value, selected()?.value)}
|
||||
current={isDeepEqual(option.value, props.current)}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<box paddingRight={2} paddingLeft={3} paddingBottom={1} flexDirection="row">
|
||||
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
|
||||
n
|
||||
</text>
|
||||
<text fg={Theme.textMuted}> new</text>
|
||||
<text fg={Theme.text} attributes={TextAttributes.BOLD}>
|
||||
{" "}r
|
||||
</text>
|
||||
<text fg={Theme.textMuted}> rename</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function Option(props: { id: string; title: string; description?: string; active?: boolean; current?: boolean }) {
|
||||
return (
|
||||
<box
|
||||
// @ts-expect-error
|
||||
id={props.id}
|
||||
flexDirection="row"
|
||||
backgroundColor={props.active ? Theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text
|
||||
fg={props.active ? Theme.background : props.current ? Theme.primary : Theme.text}
|
||||
attributes={props.active ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={props.active ? Theme.background : Theme.textMuted}> {props.description}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useKeyHandler, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createContext, For, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
import { Theme } from "../context/theme"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
|
||||
const Border = {
|
||||
topLeft: "┃",
|
||||
topRight: "┃",
|
||||
bottomLeft: "┃",
|
||||
bottomRight: "┃",
|
||||
horizontal: "",
|
||||
vertical: "┃",
|
||||
topT: "+",
|
||||
bottomT: "+",
|
||||
leftT: "+",
|
||||
rightT: "+",
|
||||
cross: "+",
|
||||
}
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
size?: "medium" | "large"
|
||||
}>,
|
||||
) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
paddingTop={dimensions().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
||||
>
|
||||
<box
|
||||
customBorderChars={Border}
|
||||
width={props.size === "large" ? 80 : 60}
|
||||
maxWidth={dimensions().width - 2}
|
||||
backgroundColor={Theme.backgroundPanel}
|
||||
borderColor={Theme.border}
|
||||
paddingTop={1}
|
||||
>
|
||||
{props.children}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function init() {
|
||||
const [store, setStore] = createStore({
|
||||
stack: [] as JSX.Element[],
|
||||
size: "medium" as "medium" | "large",
|
||||
})
|
||||
|
||||
useKeyHandler((evt) => {
|
||||
if (evt.name === "escape") {
|
||||
setStore("stack", store.stack.slice(0, -1))
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
push(input: JSX.Element) {
|
||||
setStore(
|
||||
"stack",
|
||||
produce((val) => val.push(input)),
|
||||
)
|
||||
},
|
||||
clear() {
|
||||
setStore("size", "medium")
|
||||
setStore("stack", [])
|
||||
},
|
||||
replace(input: JSX.Element) {
|
||||
setStore("size", "medium")
|
||||
setStore("stack", [input])
|
||||
},
|
||||
get stack() {
|
||||
return store.stack
|
||||
},
|
||||
get size() {
|
||||
return store.size
|
||||
},
|
||||
setSize(size: "medium" | "large") {
|
||||
setStore("size", size)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type DialogContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<DialogContext>()
|
||||
|
||||
export function DialogProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return (
|
||||
<ctx.Provider value={value}>
|
||||
{props.children}
|
||||
<box position="absolute">
|
||||
<For each={value.stack}>
|
||||
{(item, index) => (
|
||||
<Show when={index() === 0}>
|
||||
<Dialog size={value.size}>{item}</Dialog>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useDialog must be used within a DialogProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -64,6 +64,12 @@ export const RunCommand = cmd({
|
||||
type: "string",
|
||||
describe: "agent to use",
|
||||
})
|
||||
.option("format", {
|
||||
type: "string",
|
||||
choices: ["default", "json"],
|
||||
default: "default",
|
||||
describe: "format: default (formatted) or json (raw JSON events)",
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
let message = args.message.join(" ")
|
||||
@@ -144,6 +150,20 @@ export const RunCommand = cmd({
|
||||
)
|
||||
}
|
||||
|
||||
function outputJsonEvent(type: string, data: any) {
|
||||
if (args.format === "json") {
|
||||
const jsonEvent = {
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
sessionID: session?.id,
|
||||
...data,
|
||||
}
|
||||
process.stdout.write(JSON.stringify(jsonEvent) + "\n")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let text = ""
|
||||
|
||||
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
|
||||
@@ -152,6 +172,7 @@ export const RunCommand = cmd({
|
||||
const part = evt.properties.part
|
||||
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
if (outputJsonEvent("tool_use", { part })) return
|
||||
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
||||
const title =
|
||||
part.state.title ||
|
||||
@@ -169,6 +190,7 @@ export const RunCommand = cmd({
|
||||
text = part.text
|
||||
|
||||
if (part.time?.end) {
|
||||
if (outputJsonEvent("text", { part })) return
|
||||
UI.empty()
|
||||
UI.println(UI.markdown(text))
|
||||
UI.empty()
|
||||
@@ -189,6 +211,7 @@ export const RunCommand = cmd({
|
||||
}
|
||||
errorMsg = errorMsg ? errorMsg + "\n" + err : err
|
||||
|
||||
if (outputJsonEvent("error", { error })) return
|
||||
UI.error(err)
|
||||
})
|
||||
|
||||
@@ -225,6 +248,7 @@ export const RunCommand = cmd({
|
||||
const isPiped = !process.stdout.isTTY
|
||||
if (isPiped) {
|
||||
const match = result.parts.findLast((x: any) => x.type === "text") as any
|
||||
if (outputJsonEvent("text", { text: match })) return
|
||||
if (match) process.stdout.write(UI.markdown(match.text))
|
||||
if (errorMsg) process.stdout.write(errorMsg)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Installation } from "../../installation"
|
||||
import { Config } from "../../config/config"
|
||||
import { Bus } from "../../bus"
|
||||
import { Log } from "../../util/log"
|
||||
import { FileWatcher } from "../../file/watcher"
|
||||
import { Ide } from "../../ide"
|
||||
|
||||
import { Flag } from "../../flag/flag"
|
||||
@@ -178,7 +177,6 @@ export const TuiCommand = cmd({
|
||||
.then(() => Bus.publish(Ide.Event.Installed, { ide }))
|
||||
.catch(() => {})
|
||||
})()
|
||||
FileWatcher.init()
|
||||
|
||||
await proc.exited
|
||||
server.stop()
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Flag } from "../flag/flag"
|
||||
import { Auth } from "../auth"
|
||||
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
|
||||
import { Instance } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
@@ -42,12 +43,14 @@ export namespace Config {
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
process.env[value.key] = value.token
|
||||
const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
|
||||
const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
|
||||
result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
|
||||
}
|
||||
}
|
||||
|
||||
result.agent = result.agent || {}
|
||||
result.mode = result.mode || {}
|
||||
result.plugin = result.plugin || []
|
||||
|
||||
const directories = [
|
||||
Global.Path.config,
|
||||
@@ -56,109 +59,14 @@ export namespace Config {
|
||||
)),
|
||||
]
|
||||
|
||||
const markdownAgents = [
|
||||
...(await Filesystem.globUp("agent/**/*.md", Global.Path.config, Global.Path.config)),
|
||||
...(await Filesystem.globUp(".opencode/agent/**/*.md", Instance.directory, Instance.worktree)),
|
||||
]
|
||||
for (const item of markdownAgents) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
|
||||
// Extract relative path from agent folder for nested agents
|
||||
let agentName = path.basename(item, ".md")
|
||||
const agentFolderPath = item.includes("/.opencode/agent/")
|
||||
? item.split("/.opencode/agent/")[1]
|
||||
: item.includes("/agent/")
|
||||
? item.split("/agent/")[1]
|
||||
: agentName + ".md"
|
||||
|
||||
// If agent is in a subfolder, include folder path in name
|
||||
if (agentFolderPath.includes("/")) {
|
||||
const relativePath = agentFolderPath.replace(".md", "")
|
||||
const pathParts = relativePath.split("/")
|
||||
agentName = pathParts.slice(0, -1).join("/") + "/" + pathParts[pathParts.length - 1]
|
||||
}
|
||||
|
||||
const config = {
|
||||
name: agentName,
|
||||
...md.data,
|
||||
prompt: md.content.trim(),
|
||||
}
|
||||
const parsed = Agent.safeParse(config)
|
||||
if (parsed.success) {
|
||||
result.agent = mergeDeep(result.agent, {
|
||||
[config.name]: parsed.data,
|
||||
})
|
||||
continue
|
||||
}
|
||||
throw new InvalidError({ path: item }, { cause: parsed.error })
|
||||
for (const dir of directories) {
|
||||
await assertValid(dir).catch(() => {})
|
||||
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
|
||||
result.agent = mergeDeep(result.agent, await loadAgent(dir))
|
||||
result.agent = mergeDeep(result.agent, await loadMode(dir))
|
||||
result.plugin.push(...(await loadPlugin(dir)))
|
||||
}
|
||||
|
||||
// Load mode markdown files
|
||||
result.mode = result.mode || {}
|
||||
const markdownModes = [
|
||||
...(await Filesystem.globUp("mode/*.md", Global.Path.config, Global.Path.config)),
|
||||
...(await Filesystem.globUp(".opencode/mode/*.md", Instance.directory, Instance.worktree)),
|
||||
]
|
||||
for (const item of markdownModes) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
|
||||
const config = {
|
||||
name: path.basename(item, ".md"),
|
||||
...md.data,
|
||||
prompt: md.content.trim(),
|
||||
}
|
||||
const parsed = Agent.safeParse(config)
|
||||
if (parsed.success) {
|
||||
result.agent = mergeDeep(result.mode, {
|
||||
[config.name]: {
|
||||
...parsed.data,
|
||||
mode: "primary" as const,
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Load command markdown files
|
||||
result.command = result.command || {}
|
||||
const markdownCommands = [
|
||||
...(await Filesystem.globUp("command/**/*.md", Global.Path.config, Global.Path.config)),
|
||||
...(await Filesystem.globUp(".opencode/command/**/*.md", Instance.directory, Instance.worktree)),
|
||||
]
|
||||
for (const item of markdownCommands) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
|
||||
const name = (() => {
|
||||
const patterns = ["/.opencode/command/", "/command/"]
|
||||
const pattern = patterns.find((p) => item.includes(p))
|
||||
|
||||
if (pattern) {
|
||||
const index = item.indexOf(pattern)
|
||||
return item.slice(index + pattern.length, -3)
|
||||
}
|
||||
return path.basename(item, ".md")
|
||||
})()
|
||||
|
||||
const config = {
|
||||
name,
|
||||
...md.data,
|
||||
template: md.content.trim(),
|
||||
}
|
||||
const parsed = Command.safeParse(config)
|
||||
if (parsed.success) {
|
||||
result.command = mergeDeep(result.command, {
|
||||
[config.name]: parsed.data,
|
||||
})
|
||||
continue
|
||||
}
|
||||
throw new InvalidError({ path: item }, { cause: parsed.error })
|
||||
}
|
||||
// Migrate deprecated mode field to agent field
|
||||
for (const [name, mode] of Object.entries(result.mode)) {
|
||||
result.agent = mergeDeep(result.agent ?? {}, {
|
||||
@@ -169,14 +77,6 @@ export namespace Config {
|
||||
})
|
||||
}
|
||||
|
||||
result.plugin = result.plugin || []
|
||||
result.plugin.push(
|
||||
...[
|
||||
...(await Filesystem.globUp("plugin/*.{ts,js}", Global.Path.config, Global.Path.config)),
|
||||
...(await Filesystem.globUp(".opencode/plugin/*.{ts,js}", Instance.directory, Instance.worktree)),
|
||||
].map((x) => "file://" + x),
|
||||
)
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
}
|
||||
@@ -217,6 +117,134 @@ export namespace Config {
|
||||
}
|
||||
})
|
||||
|
||||
async function assertValid(dir: string) {
|
||||
const ALLOWED_DIRS = new Set(["agent", "command", "mode", "plugin", "tool", "themes"])
|
||||
const UNEXPECTED_DIR_GLOB = new Bun.Glob("*/")
|
||||
for await (const item of UNEXPECTED_DIR_GLOB.scan({
|
||||
absolute: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
cwd: dir,
|
||||
onlyFiles: false,
|
||||
})) {
|
||||
const dirname = path.basename(item)
|
||||
if (!ALLOWED_DIRS.has(dirname)) {
|
||||
throw new InvalidError({
|
||||
path: dir,
|
||||
message: `Unexpected directory "${dirname}" found in "${dir}". Only ${ALLOWED_DIRS.values().toArray().join(", ")} directories are allowed.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
|
||||
async function loadCommand(dir: string) {
|
||||
const result: Record<string, Command> = {}
|
||||
for await (const item of COMMAND_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
|
||||
const name = (() => {
|
||||
const patterns = ["/.opencode/command/", "/command/"]
|
||||
const pattern = patterns.find((p) => item.includes(p))
|
||||
|
||||
if (pattern) {
|
||||
const index = item.indexOf(pattern)
|
||||
return item.slice(index + pattern.length, -3)
|
||||
}
|
||||
return path.basename(item, ".md")
|
||||
})()
|
||||
|
||||
const config = {
|
||||
name,
|
||||
...md.data,
|
||||
template: md.content.trim(),
|
||||
}
|
||||
const parsed = Command.safeParse(config)
|
||||
if (parsed.success) {
|
||||
result[config.name] = parsed.data
|
||||
continue
|
||||
}
|
||||
throw new InvalidError({ path: item }, { cause: parsed.error })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const AGENT_GLOB = new Bun.Glob("agent/**/*.md")
|
||||
async function loadAgent(dir: string) {
|
||||
const result: Record<string, Agent> = {}
|
||||
|
||||
for await (const item of AGENT_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
|
||||
// Extract relative path from agent folder for nested agents
|
||||
let agentName = path.basename(item, ".md")
|
||||
const agentFolderPath = item.includes("/.opencode/agent/")
|
||||
? item.split("/.opencode/agent/")[1]
|
||||
: item.includes("/agent/")
|
||||
? item.split("/agent/")[1]
|
||||
: agentName + ".md"
|
||||
|
||||
// If agent is in a subfolder, include folder path in name
|
||||
if (agentFolderPath.includes("/")) {
|
||||
const relativePath = agentFolderPath.replace(".md", "")
|
||||
const pathParts = relativePath.split("/")
|
||||
agentName = pathParts.slice(0, -1).join("/") + "/" + pathParts[pathParts.length - 1]
|
||||
}
|
||||
|
||||
const config = {
|
||||
name: agentName,
|
||||
...md.data,
|
||||
prompt: md.content.trim(),
|
||||
}
|
||||
const parsed = Agent.safeParse(config)
|
||||
if (parsed.success) {
|
||||
result[config.name] = parsed.data
|
||||
continue
|
||||
}
|
||||
throw new InvalidError({ path: item }, { cause: parsed.error })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const MODE_GLOB = new Bun.Glob("mode/*.md")
|
||||
async function loadMode(dir: string) {
|
||||
const result: Record<string, Agent> = {}
|
||||
for await (const item of MODE_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
|
||||
const config = {
|
||||
name: path.basename(item, ".md"),
|
||||
...md.data,
|
||||
prompt: md.content.trim(),
|
||||
}
|
||||
const parsed = Agent.safeParse(config)
|
||||
if (parsed.success) {
|
||||
result[config.name] = {
|
||||
...parsed.data,
|
||||
mode: "primary" as const,
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const PLUGIN_GLOB = new Bun.Glob("plugin/*.{ts,js}")
|
||||
async function loadPlugin(dir: string) {
|
||||
const plugins: string[] = []
|
||||
|
||||
for await (const item of PLUGIN_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) {
|
||||
plugins.push("file://" + item)
|
||||
}
|
||||
return plugins
|
||||
}
|
||||
|
||||
export const McpLocal = z
|
||||
.object({
|
||||
type: z.literal("local").describe("Type of MCP server connection"),
|
||||
@@ -482,7 +510,22 @@ export namespace Config {
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.optional(),
|
||||
.optional()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data) return true
|
||||
const serverIds = new Set(Object.values(LSPServer).map((s) => s.id))
|
||||
|
||||
return Object.entries(data).every(([id, config]) => {
|
||||
if (config.disabled) return true
|
||||
if (serverIds.has(id)) return true
|
||||
return Boolean(config.extensions)
|
||||
})
|
||||
},
|
||||
{
|
||||
error: "For custom LSP servers, 'extensions' array is required.",
|
||||
},
|
||||
),
|
||||
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
|
||||
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
|
||||
permission: z
|
||||
@@ -593,10 +636,7 @@ export namespace Config {
|
||||
const errMsg = `bad file reference: "${match}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: configFilepath,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ path: configFilepath, message: errMsg + ` ${resolvedPath} does not exist` },
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
@@ -650,10 +690,7 @@ export namespace Config {
|
||||
return data
|
||||
}
|
||||
|
||||
throw new InvalidError({
|
||||
path: configFilepath,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
throw new InvalidError({ path: configFilepath, issues: parsed.error.issues })
|
||||
}
|
||||
export const JsonError = NamedError.create(
|
||||
"ConfigJsonError",
|
||||
@@ -676,6 +713,13 @@ export namespace Config {
|
||||
return state().then((x) => x.config)
|
||||
}
|
||||
|
||||
export async function update(config: Info) {
|
||||
const filepath = path.join(Instance.directory, "config.json")
|
||||
const existing = await loadFile(filepath)
|
||||
await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2))
|
||||
await Instance.dispose()
|
||||
}
|
||||
|
||||
export async function directories() {
|
||||
return state().then((x) => x.directories)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ export namespace FileIgnore {
|
||||
"**/.pnpm-store/**",
|
||||
"**/vendor/**",
|
||||
|
||||
// vcs
|
||||
"**/.git/**",
|
||||
|
||||
// Build outputs
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
@@ -50,8 +47,12 @@ export namespace FileIgnore {
|
||||
filepath: string,
|
||||
opts: {
|
||||
extra?: Bun.Glob[]
|
||||
whitelist?: Bun.Glob[]
|
||||
},
|
||||
) {
|
||||
for (const glob of opts.whitelist || []) {
|
||||
if (glob.match(filepath)) return false
|
||||
}
|
||||
const extra = opts.extra || []
|
||||
for (const glob of [...GLOBS, ...extra]) {
|
||||
if (glob.match(filepath)) return true
|
||||
|
||||
@@ -181,9 +181,7 @@ export namespace File {
|
||||
}
|
||||
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
|
||||
const nodes: Node[] = []
|
||||
for (const entry of await fs.promises.readdir(resolved, {
|
||||
withFileTypes: true,
|
||||
})) {
|
||||
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true })) {
|
||||
if (exclude.includes(entry.name)) continue
|
||||
const fullPath = path.join(resolved, entry.name)
|
||||
const relativePath = path.relative(Instance.directory, fullPath)
|
||||
|
||||
@@ -28,9 +28,9 @@ export namespace FileWatcher {
|
||||
const ignore = (cfg.watcher?.ignore ?? []).map((v) => new Bun.Glob(v))
|
||||
const watcher = chokidar.watch(Instance.directory, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: true,
|
||||
ignored: (filepath) => {
|
||||
return FileIgnore.match(filepath, {
|
||||
whitelist: [new Bun.Glob("**/.git/{index,logs/HEAD}")],
|
||||
extra: ignore,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "fs/promises"
|
||||
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
|
||||
const app = "opencode"
|
||||
|
||||
@@ -12,7 +11,6 @@ const state = path.join(xdgState!, app)
|
||||
|
||||
export namespace Global {
|
||||
export const Path = {
|
||||
home: os.homedir(),
|
||||
data,
|
||||
bin: path.join(data, "bin"),
|
||||
log: path.join(data, "log"),
|
||||
@@ -40,12 +38,7 @@ if (version !== CACHE_VERSION) {
|
||||
try {
|
||||
const contents = await fs.readdir(Global.Path.cache)
|
||||
await Promise.all(
|
||||
contents.map((item) =>
|
||||
fs.rm(path.join(Global.Path.cache, item), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
),
|
||||
contents.map((item) => fs.rm(path.join(Global.Path.cache, item), { recursive: true, force: true })),
|
||||
)
|
||||
} catch (e) {}
|
||||
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)
|
||||
|
||||
@@ -17,8 +17,8 @@ import { DebugCommand } from "./cli/cmd/debug"
|
||||
import { StatsCommand } from "./cli/cmd/stats"
|
||||
import { McpCommand } from "./cli/cmd/mcp"
|
||||
import { GithubCommand } from "./cli/cmd/github"
|
||||
import { OpentuiCommand } from "./cli/cmd/opentui/opentui"
|
||||
import { ExportCommand } from "./cli/cmd/export"
|
||||
import { AttachCommand } from "./cli/cmd/attach"
|
||||
|
||||
const cancel = new AbortController()
|
||||
|
||||
@@ -72,7 +72,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
.usage("\n" + UI.logo())
|
||||
.command(McpCommand)
|
||||
.command(TuiCommand)
|
||||
.command(OpentuiCommand)
|
||||
.command(AttachCommand)
|
||||
.command(RunCommand)
|
||||
.command(GenerateCommand)
|
||||
.command(DebugCommand)
|
||||
|
||||
@@ -141,7 +141,7 @@ export namespace Installation {
|
||||
export async function latest() {
|
||||
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
||||
.then((res) => res.json())
|
||||
.then((data: any) => {
|
||||
.then((data) => {
|
||||
if (typeof data.tag_name !== "string") {
|
||||
log.error("GitHub API error", data)
|
||||
throw new Error("failed to fetch latest version")
|
||||
|
||||
@@ -139,10 +139,7 @@ export namespace LSPClient {
|
||||
if (version !== undefined) {
|
||||
const next = version + 1
|
||||
files[input.path] = next
|
||||
log.info("textDocument/didChange", {
|
||||
path: input.path,
|
||||
version: next,
|
||||
})
|
||||
log.info("textDocument/didChange", { path: input.path, version: next })
|
||||
await connection.sendNotification("textDocument/didChange", {
|
||||
textDocument: {
|
||||
uri: `file://` + input.path,
|
||||
|
||||
@@ -72,7 +72,7 @@ export namespace LSP {
|
||||
...existing,
|
||||
id: name,
|
||||
root: existing?.root ?? (async () => Instance.directory),
|
||||
extensions: item.extensions ?? existing.extensions,
|
||||
extensions: item.extensions ?? existing?.extensions ?? [],
|
||||
spawn: async (root) => {
|
||||
return {
|
||||
process: spawn(item.command[0], item.command.slice(1), {
|
||||
@@ -126,13 +126,22 @@ export namespace LSP {
|
||||
result.push(match)
|
||||
continue
|
||||
}
|
||||
log.info("spawning lsp server", { serverID: server.id })
|
||||
const handle = await server.spawn(root).catch((err) => {
|
||||
s.broken.add(root + server.id)
|
||||
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
const handle = await server
|
||||
.spawn(root)
|
||||
.then((h) => {
|
||||
if (h === undefined) {
|
||||
s.broken.add(root + server.id)
|
||||
}
|
||||
return h
|
||||
})
|
||||
.catch((err) => {
|
||||
s.broken.add(root + server.id)
|
||||
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
if (!handle) continue
|
||||
log.info("spawned lsp server", { serverID: server.id })
|
||||
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
@@ -140,9 +149,7 @@ export namespace LSP {
|
||||
}).catch((err) => {
|
||||
s.broken.add(root + server.id)
|
||||
handle.process.kill()
|
||||
log.error(`Failed to initialize LSP client ${server.id}`, {
|
||||
error: err,
|
||||
})
|
||||
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
if (!client) continue
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "child_process"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
@@ -410,7 +411,7 @@ export namespace LSPServer {
|
||||
return
|
||||
}
|
||||
|
||||
const release = (await releaseResponse.json()) as any
|
||||
const release = await releaseResponse.json()
|
||||
|
||||
const platform = process.platform
|
||||
const arch = process.arch
|
||||
@@ -595,7 +596,7 @@ export namespace LSPServer {
|
||||
return
|
||||
}
|
||||
|
||||
const release = (await releaseResponse.json()) as any
|
||||
const release = await releaseResponse.json()
|
||||
|
||||
const platform = process.platform
|
||||
let assetName = ""
|
||||
@@ -706,4 +707,91 @@ export namespace LSPServer {
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const JDTLS: Info = {
|
||||
id: "jdtls",
|
||||
root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]),
|
||||
extensions: [".java"],
|
||||
async spawn(root) {
|
||||
const java = Bun.which("java")
|
||||
if (!java) {
|
||||
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
|
||||
return
|
||||
}
|
||||
const javaMajorVersion = await $`java -version`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.then(({ stderr }) => {
|
||||
const m = /"(\d+)\.\d+\.\d+"/.exec(stderr.toString())
|
||||
return !m ? undefined : parseInt(m[1])
|
||||
})
|
||||
if (javaMajorVersion == null || javaMajorVersion < 21) {
|
||||
log.error("JDTLS requires at least Java 21.")
|
||||
return
|
||||
}
|
||||
const distPath = path.join(Global.Path.bin, "jdtls")
|
||||
const launcherDir = path.join(distPath, "plugins")
|
||||
const installed = await fs.exists(launcherDir)
|
||||
if (!installed) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("Downloading JDTLS LSP server.")
|
||||
await fs.mkdir(distPath, { recursive: true })
|
||||
const releaseURL =
|
||||
"https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz"
|
||||
const archivePath = path.join(distPath, "release.tar.gz")
|
||||
await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow()
|
||||
await $`tar -xzf ${archivePath}`.cwd(distPath).quiet().nothrow()
|
||||
await fs.rm(archivePath, { force: true })
|
||||
}
|
||||
const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar`
|
||||
.cwd(launcherDir)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.then(({ stdout }) => stdout.toString().trim())
|
||||
const launcherJar = path.join(launcherDir, jarFileName)
|
||||
if (!(await fs.exists(launcherJar))) {
|
||||
log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
|
||||
return
|
||||
}
|
||||
const configFile = path.join(
|
||||
distPath,
|
||||
(() => {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "config_mac"
|
||||
case "linux":
|
||||
return "config_linux"
|
||||
case "win32":
|
||||
return "config_windows"
|
||||
default:
|
||||
return "config_linux"
|
||||
}
|
||||
})(),
|
||||
)
|
||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jdtls-data"))
|
||||
return {
|
||||
process: spawn(
|
||||
java,
|
||||
[
|
||||
"-jar",
|
||||
launcherJar,
|
||||
"-configuration",
|
||||
configFile,
|
||||
"-data",
|
||||
dataDir,
|
||||
"-Declipse.application=org.eclipse.jdt.ls.core.id1",
|
||||
"-Dosgi.bundles.defaultStartLevel=4",
|
||||
"-Declipse.product=org.eclipse.jdt.ls.core.product",
|
||||
"-Dlog.level=ALL",
|
||||
"--add-modules=ALL-SYSTEM",
|
||||
"--add-opens java.base/java.util=ALL-UNNAMED",
|
||||
"--add-opens java.base/java.lang=ALL-UNNAMED",
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
},
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +67,7 @@ export namespace MCP {
|
||||
return null
|
||||
})
|
||||
if (client) {
|
||||
log.debug("transport connection succeeded", {
|
||||
key,
|
||||
transport: name,
|
||||
})
|
||||
log.debug("transport connection succeeded", { key, transport: name })
|
||||
clients[key] = client
|
||||
break
|
||||
}
|
||||
@@ -79,11 +76,7 @@ export namespace MCP {
|
||||
const errorMessage = lastError
|
||||
? `MCP server ${key} failed to connect: ${lastError.message}`
|
||||
: `MCP server ${key} failed to connect to ${mcp.url}`
|
||||
log.error("remote mcp connection failed", {
|
||||
key,
|
||||
url: mcp.url,
|
||||
error: lastError?.message,
|
||||
})
|
||||
log.error("remote mcp connection failed", { key, url: mcp.url, error: lastError?.message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
|
||||
@@ -41,11 +41,7 @@ export namespace Permission {
|
||||
Updated: Bus.event("permission.updated", Info),
|
||||
Replied: Bus.event(
|
||||
"permission.replied",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
permissionID: z.string(),
|
||||
response: z.string(),
|
||||
}),
|
||||
z.object({ sessionID: z.string(), permissionID: z.string(), response: z.string() }),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export namespace Plugin {
|
||||
const state = Instance.state(async () => {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
// @ts-expect-error
|
||||
fetch: async (...args) => Server.App().fetch(...args),
|
||||
})
|
||||
const config = await Config.get()
|
||||
@@ -28,7 +27,7 @@ export namespace Plugin {
|
||||
}
|
||||
const plugins = [...(config.plugin ?? [])]
|
||||
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||
plugins.push("opencode-copilot-auth@0.0.2")
|
||||
plugins.push("opencode-copilot-auth@0.0.3")
|
||||
plugins.push("opencode-anthropic-auth@0.0.2")
|
||||
}
|
||||
for (let plugin of plugins) {
|
||||
|
||||
@@ -16,10 +16,7 @@ import { Flag } from "../flag/flag"
|
||||
export namespace Provider {
|
||||
const log = Log.create({ service: "provider" })
|
||||
|
||||
type CustomLoader = (
|
||||
provider: ModelsDev.Provider,
|
||||
api?: string,
|
||||
) => Promise<{
|
||||
type CustomLoader = (provider: ModelsDev.Provider) => Promise<{
|
||||
autoload: boolean
|
||||
getModel?: (sdk: any, modelID: string) => Promise<any>
|
||||
options?: Record<string, any>
|
||||
@@ -40,6 +37,19 @@ export namespace Provider {
|
||||
}
|
||||
},
|
||||
async opencode(input) {
|
||||
const hasKey = await (async () => {
|
||||
if (input.env.some((item) => process.env[item])) return true
|
||||
if (await Auth.get(input.id)) return true
|
||||
return false
|
||||
})()
|
||||
|
||||
if (!hasKey) {
|
||||
for (const [key, value] of Object.entries(input.models)) {
|
||||
if (value.cost.input === 0) continue
|
||||
delete input.models[key]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
autoload: Object.keys(input.models).length > 0,
|
||||
options: {},
|
||||
@@ -328,6 +338,9 @@ export namespace Provider {
|
||||
const s = await state()
|
||||
const pkg = model.provider?.npm ?? provider.npm ?? provider.id
|
||||
const options = { ...s.providers[provider.id]?.options }
|
||||
if (pkg.includes("@ai-sdk/openai-compatible") && options["includeUsage"] === undefined) {
|
||||
options["includeUsage"] = true
|
||||
}
|
||||
const key = Bun.hash.xxHash32(JSON.stringify({ pkg, options }))
|
||||
const existing = s.sdk.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
@@ -92,14 +92,14 @@ export namespace ProviderTransform {
|
||||
}
|
||||
|
||||
if (modelID.includes("gpt-5") && !modelID.includes("gpt-5-chat")) {
|
||||
result["reasoningEffort"] = "high"
|
||||
result["reasoningEffort"] = "medium"
|
||||
if (providerID !== "azure") {
|
||||
result["textVerbosity"] = "low"
|
||||
result["textVerbosity"] = modelID.includes("codex") ? "medium" : "low"
|
||||
}
|
||||
if (providerID === "opencode") {
|
||||
result["promptCacheKey"] = sessionID
|
||||
// result["include"] = ["reasoning.encrypted_content"]
|
||||
// result["reasoningSummary"] = "detailed"
|
||||
result["include"] = ["reasoning.encrypted_content"]
|
||||
result["reasoningSummary"] = "detailed"
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -29,7 +29,6 @@ import { SessionPrompt } from "../session/prompt"
|
||||
import { SessionCompaction } from "../session/compaction"
|
||||
import { SessionRevert } from "../session/revert"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Todo } from "../session/todo"
|
||||
import { InstanceBootstrap } from "../project/bootstrap"
|
||||
|
||||
const ERRORS = {
|
||||
@@ -136,6 +135,30 @@ export namespace Server {
|
||||
return c.json(await Config.get())
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/config",
|
||||
describeRoute({
|
||||
description: "Update config",
|
||||
operationId: "config.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated config",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...ERRORS,
|
||||
},
|
||||
}),
|
||||
validator("json", Config.Info),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
await Config.update(config)
|
||||
return c.json(config)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/experimental/tool/ids",
|
||||
describeRoute({
|
||||
@@ -320,34 +343,6 @@ export namespace Server {
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/session/:id/todo",
|
||||
describeRoute({
|
||||
description: "Get the todo list for a session",
|
||||
operationId: "session.todo",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Todo list",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Todo.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().meta({ description: "Session ID" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").id
|
||||
const todos = await Todo.get(sessionID)
|
||||
return c.json(todos)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session",
|
||||
describeRoute({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user