mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-02 23:04:07 +08:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
184834da98 | ||
|
|
008a5c10cc | ||
|
|
2d5b9a5cc6 | ||
|
|
fb3ca895d6 | ||
|
|
d3d379fe2e | ||
|
|
b41626049c | ||
|
|
e59be27810 | ||
|
|
1e2992244f | ||
|
|
fd22b26478 | ||
|
|
ea2ee46f45 | ||
|
|
4e1b6b3417 | ||
|
|
2d52a461a0 | ||
|
|
9cce0cf4f4 | ||
|
|
a41c8508da | ||
|
|
4f7458b47d | ||
|
|
270cd05195 | ||
|
|
24c933ae60 | ||
|
|
2b7a021ba3 | ||
|
|
cbf87c50b9 | ||
|
|
3c375b971e | ||
|
|
6590c1641f | ||
|
|
0ffe496869 | ||
|
|
ce4e595881 | ||
|
|
e91cc7e514 | ||
|
|
c961072d20 | ||
|
|
429240f439 | ||
|
|
a0dc90bfcc | ||
|
|
6bac501be5 | ||
|
|
b5be883758 | ||
|
|
0021a09ba8 | ||
|
|
a8c2928a87 | ||
|
|
79f6910697 | ||
|
|
04cea9cf11 | ||
|
|
61c334f1fb | ||
|
|
85ed329318 | ||
|
|
37decee795 | ||
|
|
5d0007ade4 | ||
|
|
31dd9fd13a | ||
|
|
23fc675ad5 | ||
|
|
22b058a33d | ||
|
|
939c0940aa | ||
|
|
fd7b7eacd3 | ||
|
|
eaa0826e7f | ||
|
|
f6055ad3d2 | ||
|
|
761863ae35 | ||
|
|
dadc08ddc7 | ||
|
|
d7afb01d13 | ||
|
|
5168988156 | ||
|
|
9a3bd0ade1 | ||
|
|
4e5b0b00b0 | ||
|
|
7672b722ca | ||
|
|
488c3502a7 | ||
|
|
a2f80f7c0d | ||
|
|
dee0226741 | ||
|
|
58aba1c797 | ||
|
|
9906d42e1c | ||
|
|
43eefbc349 | ||
|
|
625c9dae5c |
1
STATS.md
1
STATS.md
@@ -193,3 +193,4 @@
|
||||
| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) |
|
||||
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
|
||||
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
|
||||
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
## Style Guide
|
||||
|
||||
- Try to keep things in one function unless composable or reusable
|
||||
- DO NOT do unnecessary destructuring of variables
|
||||
- DO NOT use `else` statements unless necessary
|
||||
- DO NOT use `try`/`catch` if it can be avoided
|
||||
- AVOID unnecessary destructuring of variables
|
||||
- AVOID `try`/`catch` where possible
|
||||
- AVOID `else` statements
|
||||
- AVOID using `any` type
|
||||
|
||||
32
bun.lock
32
bun.lock
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -125,7 +125,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -149,7 +149,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -173,9 +173,10 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
@@ -201,7 +202,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -230,7 +231,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -246,7 +247,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -349,7 +350,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -369,7 +370,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -380,7 +381,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -393,7 +394,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -401,6 +402,7 @@
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -431,7 +433,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -442,7 +444,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -76,6 +76,7 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
||||
"checkout.session.completed",
|
||||
"checkout.session.expired",
|
||||
"charge.refunded",
|
||||
"invoice.payment_succeeded",
|
||||
"customer.created",
|
||||
"customer.deleted",
|
||||
"customer.updated",
|
||||
@@ -119,6 +120,7 @@ const ZEN_MODELS = [
|
||||
new sst.Secret("ZEN_MODELS6"),
|
||||
new sst.Secret("ZEN_MODELS7"),
|
||||
]
|
||||
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
|
||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||
properties: { value: auth.url.apply((url) => url!) },
|
||||
@@ -160,6 +162,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
ZEN_BLACK,
|
||||
...ZEN_MODELS,
|
||||
...($dev
|
||||
? [
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"nodeModules": "sha256-mZGKIkOLmesEhCpEZTLiPbBisZOxdZ1NgqnRnVHJlLU="
|
||||
"nodeModules": "sha256-WHqX159BYPSHBFmxxkTrWPytBzTSTcWkoEywAxP58kI="
|
||||
}
|
||||
|
||||
@@ -60,7 +60,12 @@ const result = await Bun.build({
|
||||
compile: {
|
||||
target,
|
||||
outfile: "opencode",
|
||||
execArgv: ["--user-agent=opencode/" + version, '--env-file=""', "--"],
|
||||
autoloadBunfig: false,
|
||||
autoloadDotenv: false,
|
||||
//@ts-ignore (bun types aren't up to date)
|
||||
autoloadTsconfig: true,
|
||||
autoloadPackageJson: true,
|
||||
execArgv: ["--user-agent=opencode/" + version, "--use-system-ca", "--"],
|
||||
windows: {},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@/index.css"
|
||||
import { ErrorBoundary, Show, Suspense, lazy, type ParentProps } from "solid-js"
|
||||
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
|
||||
import { Router, Route, Navigate } from "@solidjs/router"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
@@ -20,10 +20,12 @@ import { FileProvider } from "@/context/file"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import Layout from "@/pages/layout"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { Suspense } from "solid-js"
|
||||
|
||||
const Home = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
@@ -31,7 +33,7 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: { updaterEnabled?: boolean; port?: number }
|
||||
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +49,25 @@ const defaultServerUrl = iife(() => {
|
||||
return window.location.origin
|
||||
})
|
||||
|
||||
export function AppBaseProviders(props: ParentProps) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
<ThemeProvider>
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<DiffComponentProvider component={Diff}>
|
||||
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
|
||||
</DiffComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</MetaProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerKey(props: ParentProps) {
|
||||
const server = useServer()
|
||||
return (
|
||||
@@ -56,71 +77,56 @@ function ServerKey(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
export function AppInterface() {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
<ThemeProvider>
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<DiffComponentProvider component={Diff}>
|
||||
<CodeComponentProvider component={Code}>
|
||||
<ServerProvider defaultUrl={defaultServerUrl}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Router
|
||||
root={(props) => (
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<CommandProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</CommandProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)}
|
||||
>
|
||||
<Route
|
||||
path="/"
|
||||
component={() => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Home />
|
||||
</Suspense>
|
||||
)}
|
||||
/>
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route
|
||||
path="/session/:id?"
|
||||
component={(p) => (
|
||||
<Show when={p.params.id ?? "new"} keyed>
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Session />
|
||||
</Suspense>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</Route>
|
||||
</Router>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</ServerKey>
|
||||
</ServerProvider>
|
||||
</CodeComponentProvider>
|
||||
</DiffComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</MetaProvider>
|
||||
<ServerProvider defaultUrl={defaultServerUrl}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Router
|
||||
root={(props) => (
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<CommandProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</CommandProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)}
|
||||
>
|
||||
<Route
|
||||
path="/"
|
||||
component={() => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Home />
|
||||
</Suspense>
|
||||
)}
|
||||
/>
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route
|
||||
path="/session/:id?"
|
||||
component={(p) => (
|
||||
<Show when={p.params.id ?? "new"} keyed>
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Session />
|
||||
</Suspense>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</Route>
|
||||
</Router>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</ServerKey>
|
||||
</ServerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { usePermission } from "@/context/permission"
|
||||
@@ -189,7 +189,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
const [history, setHistory] = persisted(
|
||||
"prompt-history.v1",
|
||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||
createStore<{
|
||||
entries: Prompt[]
|
||||
}>({
|
||||
@@ -197,7 +197,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}),
|
||||
)
|
||||
const [shellHistory, setShellHistory] = persisted(
|
||||
"prompt-history-shell.v1",
|
||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||
createStore<{
|
||||
entries: Prompt[]
|
||||
}>({
|
||||
@@ -248,6 +248,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isFocused = createFocusSignal(() => editorRef)
|
||||
|
||||
createEffect(() => {
|
||||
params.id
|
||||
editorRef.focus()
|
||||
@@ -258,7 +260,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const isFocused = createFocusSignal(() => editorRef)
|
||||
const [composing, setComposing] = createSignal(false)
|
||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||
|
||||
@@ -292,12 +293,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const items = Array.from(clipboardData.items)
|
||||
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
||||
|
||||
if (imageItems.length > 0) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
for (const item of imageItems) {
|
||||
const file = item.getAsFile()
|
||||
if (file) await addImageAttachment(file)
|
||||
@@ -305,8 +307,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const plainText = clipboardData.getData("text/plain") ?? ""
|
||||
addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||
}
|
||||
@@ -347,13 +347,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editorRef.addEventListener("paste", handlePaste)
|
||||
document.addEventListener("dragover", handleGlobalDragOver)
|
||||
document.addEventListener("dragleave", handleGlobalDragLeave)
|
||||
document.addEventListener("drop", handleGlobalDrop)
|
||||
})
|
||||
onCleanup(() => {
|
||||
editorRef.removeEventListener("paste", handlePaste)
|
||||
document.removeEventListener("dragover", handleGlobalDragOver)
|
||||
document.removeEventListener("dragleave", handleGlobalDragLeave)
|
||||
document.removeEventListener("drop", handleGlobalDrop)
|
||||
@@ -1508,6 +1506,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}}
|
||||
contenteditable="true"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -1593,14 +1592,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||
classList={{
|
||||
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||
"text-text-base": !permission.isAutoAccepting(params.id!),
|
||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!),
|
||||
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name="chevron-double-right"
|
||||
size="small"
|
||||
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }}
|
||||
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitPr
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
import { LocalPTY } from "@/context/terminal"
|
||||
import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
|
||||
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
|
||||
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
@@ -16,6 +16,7 @@ type TerminalColors = {
|
||||
background: string
|
||||
foreground: string
|
||||
cursor: string
|
||||
selectionBackground: string
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
||||
@@ -23,11 +24,13 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
||||
background: "#fcfcfc",
|
||||
foreground: "#211e1e",
|
||||
cursor: "#211e1e",
|
||||
selectionBackground: withAlpha("#211e1e", 0.2),
|
||||
},
|
||||
dark: {
|
||||
background: "#191515",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
selectionBackground: withAlpha("#d4d4d4", 0.25),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -55,10 +58,14 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||
const text = resolved["text-stronger"] ?? fallback.foreground
|
||||
const background = resolved["background-stronger"] ?? fallback.background
|
||||
const alpha = mode === "dark" ? 0.25 : 0.2
|
||||
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
|
||||
const selectionBackground = withAlpha(base, alpha)
|
||||
return {
|
||||
background,
|
||||
foreground: text,
|
||||
cursor: text,
|
||||
selectionBackground,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
@@ -7,7 +7,7 @@ import { useParams } from "@solidjs/router"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
export type FileSelection = {
|
||||
startLine: number
|
||||
@@ -134,10 +134,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
file: {},
|
||||
})
|
||||
|
||||
const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
|
||||
const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
|
||||
|
||||
const [view, setView, _, ready] = persisted(
|
||||
viewKey(),
|
||||
Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]),
|
||||
createStore<{
|
||||
file: Record<string, FileViewState>
|
||||
}>({
|
||||
@@ -145,6 +145,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
}),
|
||||
)
|
||||
|
||||
const MAX_VIEW_FILES = 500
|
||||
const viewMeta = { pruned: false }
|
||||
|
||||
const pruneView = (keep?: string) => {
|
||||
const keys = Object.keys(view.file)
|
||||
if (keys.length <= MAX_VIEW_FILES) return
|
||||
|
||||
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
|
||||
if (drop.length === 0) return
|
||||
|
||||
setView(
|
||||
produce((draft) => {
|
||||
for (const key of drop) {
|
||||
delete draft.file[key]
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (viewMeta.pruned) return
|
||||
viewMeta.pruned = true
|
||||
pruneView()
|
||||
})
|
||||
|
||||
function ensure(path: string) {
|
||||
if (!path) return
|
||||
if (store.file[path]) return
|
||||
@@ -233,6 +259,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
scrollTop: top,
|
||||
}
|
||||
})
|
||||
pruneView(path)
|
||||
}
|
||||
|
||||
const setScrollLeft = (input: string, left: number) => {
|
||||
@@ -244,6 +271,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
scrollLeft: left,
|
||||
}
|
||||
})
|
||||
pruneView(path)
|
||||
}
|
||||
|
||||
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
||||
@@ -256,6 +284,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
selectedLines: next,
|
||||
}
|
||||
})
|
||||
pruneView(path)
|
||||
}
|
||||
|
||||
onCleanup(() => stop())
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useGlobalSync } from "./global-sync"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useServer } from "./server"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
|
||||
@@ -46,7 +46,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const globalSync = useGlobalSync()
|
||||
const server = useServer()
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"layout.v6",
|
||||
Persist.global("layout", ["layout.v6"]),
|
||||
createStore({
|
||||
sidebar: {
|
||||
opened: false,
|
||||
@@ -75,6 +75,29 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const meta = { active: undefined as string | undefined, pruned: false }
|
||||
const used = new Map<string, number>()
|
||||
|
||||
const SESSION_STATE_KEYS = [
|
||||
{ key: "prompt", legacy: "prompt", version: "v2" },
|
||||
{ key: "terminal", legacy: "terminal", version: "v1" },
|
||||
{ key: "file-view", legacy: "file", version: "v1" },
|
||||
] as const
|
||||
|
||||
const dropSessionState = (keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
const parts = key.split("/")
|
||||
const dir = parts[0]
|
||||
const session = parts[1]
|
||||
if (!dir) continue
|
||||
|
||||
for (const entry of SESSION_STATE_KEYS) {
|
||||
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
|
||||
void removePersisted(target)
|
||||
|
||||
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
|
||||
void removePersisted({ key: legacyKey })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prune(keep?: string) {
|
||||
if (!keep) return
|
||||
|
||||
@@ -102,6 +125,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
)
|
||||
|
||||
scroll.drop(drop)
|
||||
dropSessionState(drop)
|
||||
|
||||
for (const key of drop) {
|
||||
used.delete(key)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useSync } from "./sync"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { DateTime } from "luxon"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
|
||||
export type LocalFile = FileNode &
|
||||
@@ -111,7 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const model = (() => {
|
||||
const [store, setStore, _, modelReady] = persisted(
|
||||
"model.v1",
|
||||
Persist.global("model", ["model.v1"]),
|
||||
createStore<{
|
||||
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
|
||||
recent: ModelKey[]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
@@ -10,7 +10,7 @@ import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { makeAudioPlayer } from "@solid-primitives/audio"
|
||||
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
|
||||
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
@@ -31,6 +31,16 @@ type ErrorNotification = NotificationBase & {
|
||||
|
||||
export type Notification = TurnCompleteNotification | ErrorNotification
|
||||
|
||||
const MAX_NOTIFICATIONS = 500
|
||||
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
|
||||
|
||||
function pruneNotifications(list: Notification[]) {
|
||||
const cutoff = Date.now() - NOTIFICATION_TTL_MS
|
||||
const pruned = list.filter((n) => n.time >= cutoff)
|
||||
if (pruned.length <= MAX_NOTIFICATIONS) return pruned
|
||||
return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||
name: "Notification",
|
||||
init: () => {
|
||||
@@ -49,12 +59,25 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
const platform = usePlatform()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"notification.v1",
|
||||
Persist.global("notification", ["notification.v1"]),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
)
|
||||
|
||||
const meta = { pruned: false }
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
meta.pruned = true
|
||||
setStore("list", pruneNotifications(store.list))
|
||||
})
|
||||
|
||||
const append = (notification: Notification) => {
|
||||
setStore("list", (list) => pruneNotifications([...list, notification]))
|
||||
}
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const event = e.details
|
||||
@@ -73,7 +96,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
try {
|
||||
idlePlayer?.play()
|
||||
} catch {}
|
||||
setStore("list", store.list.length, {
|
||||
append({
|
||||
...base,
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
@@ -92,7 +115,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
errorPlayer?.play()
|
||||
} catch {}
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
setStore("list", store.list.length, {
|
||||
append({
|
||||
...base,
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
type PermissionRespondFn = (input: {
|
||||
sessionID: string
|
||||
@@ -19,6 +19,32 @@ function shouldAutoAccept(perm: PermissionRequest) {
|
||||
return perm.permission === "edit"
|
||||
}
|
||||
|
||||
function isNonAllowRule(rule: unknown) {
|
||||
if (!rule) return false
|
||||
if (typeof rule === "string") return rule !== "allow"
|
||||
if (typeof rule !== "object") return false
|
||||
if (Array.isArray(rule)) return false
|
||||
|
||||
for (const action of Object.values(rule)) {
|
||||
if (action !== "allow") return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function hasAutoAcceptPermissionConfig(permission: unknown) {
|
||||
if (!permission) return false
|
||||
if (typeof permission === "string") return permission !== "allow"
|
||||
if (typeof permission !== "object") return false
|
||||
if (Array.isArray(permission)) return false
|
||||
|
||||
const config = permission as Record<string, unknown>
|
||||
if (isNonAllowRule(config.edit)) return true
|
||||
if (isNonAllowRule(config.write)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||
name: "Permission",
|
||||
init: () => {
|
||||
@@ -27,13 +53,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
const globalSync = useGlobalSync()
|
||||
|
||||
const permissionsEnabled = createMemo(() => {
|
||||
if (!params.dir || !base64Decode(params.dir)) return false
|
||||
const [store] = globalSync.child(base64Decode(params.dir))
|
||||
return store.config.permission !== undefined
|
||||
const directory = params.dir ? base64Decode(params.dir) : undefined
|
||||
if (!directory) return false
|
||||
const [store] = globalSync.child(directory)
|
||||
return hasAutoAcceptPermissionConfig(store.config.permission)
|
||||
})
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"permission.v3",
|
||||
Persist.global("permission", ["permission.v3"]),
|
||||
createStore({
|
||||
autoAcceptEdits: {} as Record<string, boolean>,
|
||||
}),
|
||||
@@ -58,8 +85,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
})
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string) {
|
||||
return store.autoAcceptEdits[sessionID] ?? false
|
||||
function acceptKey(sessionID: string, directory?: string) {
|
||||
if (!directory) return sessionID
|
||||
return `${base64Encode(directory)}/${sessionID}`
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
|
||||
}
|
||||
|
||||
const unsubscribe = globalSDK.event.listen((e) => {
|
||||
@@ -67,7 +100,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
if (event?.type !== "permission.asked") return
|
||||
|
||||
const perm = event.properties
|
||||
if (!isAutoAccepting(perm.sessionID)) return
|
||||
if (!isAutoAccepting(perm.sessionID, e.name)) return
|
||||
if (!shouldAutoAccept(perm)) return
|
||||
|
||||
respondOnce(perm, e.name)
|
||||
@@ -75,7 +108,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
onCleanup(unsubscribe)
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
setStore("autoAcceptEdits", sessionID, true)
|
||||
const key = acceptKey(sessionID, directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAcceptEdits[key] = true
|
||||
delete draft.autoAcceptEdits[sessionID]
|
||||
}),
|
||||
)
|
||||
|
||||
globalSDK.client.permission
|
||||
.list({ directory })
|
||||
@@ -90,31 +129,37 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disable(sessionID: string) {
|
||||
setStore("autoAcceptEdits", sessionID, false)
|
||||
function disable(sessionID: string, directory?: string) {
|
||||
const key = directory ? acceptKey(sessionID, directory) : undefined
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
if (key) delete draft.autoAcceptEdits[key]
|
||||
delete draft.autoAcceptEdits[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
respond,
|
||||
autoResponds(permission: PermissionRequest) {
|
||||
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
|
||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||
return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
|
||||
},
|
||||
isAutoAccepting,
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID)) {
|
||||
disable(sessionID)
|
||||
if (isAutoAccepting(sessionID, directory)) {
|
||||
disable(sessionID, directory)
|
||||
return
|
||||
}
|
||||
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID)) return
|
||||
if (isAutoAccepting(sessionID, directory)) return
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
disableAutoAccept(sessionID: string) {
|
||||
disable(sessionID)
|
||||
disableAutoAccept(sessionID: string, directory?: string) {
|
||||
disable(sessionID, directory)
|
||||
},
|
||||
permissionsEnabled,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
@@ -103,10 +103,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
name: "Prompt",
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
|
||||
const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
name(),
|
||||
Persist.scoped(params.dir!, params.id, "prompt", [legacy()]),
|
||||
createStore<{
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
|
||||
@@ -35,7 +35,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
const platform = usePlatform()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"server.v3",
|
||||
Persist.global("server", ["server.v3"]),
|
||||
createStore({
|
||||
list: [] as string[],
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
export type LocalPTY = {
|
||||
id: string
|
||||
@@ -19,10 +19,10 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const params = useParams()
|
||||
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
|
||||
const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
name(),
|
||||
Persist.scoped(params.dir!, params.id, "terminal", [legacy()]),
|
||||
createStore<{
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @refresh reload
|
||||
import { render } from "solid-js/web"
|
||||
import { App } from "@/app"
|
||||
import { AppBaseProviders, AppInterface } from "@/app"
|
||||
import { Platform, PlatformProvider } from "@/context/platform"
|
||||
import pkg from "../package.json"
|
||||
|
||||
@@ -55,7 +55,9 @@ const platform: Platform = {
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
<App />
|
||||
<AppBaseProviders>
|
||||
<AppInterface />
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
root!,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { PlatformProvider, type Platform } from "./context/platform"
|
||||
export { App } from "./app"
|
||||
export { AppBaseProviders, AppInterface } from "./app"
|
||||
|
||||
@@ -170,7 +170,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (e.details?.type !== "permission.asked") return
|
||||
const directory = e.name
|
||||
const perm = e.details.properties
|
||||
if (permission.autoResponds(perm)) return
|
||||
if (permission.autoResponds(perm, directory)) return
|
||||
|
||||
const [store] = globalSync.child(directory)
|
||||
const session = store.session.find((s) => s.id === perm.sessionID)
|
||||
@@ -266,24 +266,30 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function projectSessions(directory: string) {
|
||||
if (!directory) return []
|
||||
const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions)
|
||||
return (sessions ?? []).filter((s) => !s.parentID)
|
||||
const currentProject = createMemo(() => {
|
||||
const directory = params.dir ? base64Decode(params.dir) : undefined
|
||||
if (!directory) return
|
||||
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
||||
})
|
||||
|
||||
function projectSessions(project: LocalProject | undefined) {
|
||||
if (!project) return []
|
||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const stores = dirs.map((dir) => globalSync.child(dir)[0])
|
||||
const sessions = stores
|
||||
.flatMap((store) => store.session.filter((session) => session.directory === store.path.directory))
|
||||
.toSorted(sortSessions)
|
||||
return sessions.filter((s) => !s.parentID)
|
||||
}
|
||||
|
||||
const currentSessions = createMemo(() => {
|
||||
if (!params.dir) return []
|
||||
const directory = base64Decode(params.dir)
|
||||
return projectSessions(directory)
|
||||
})
|
||||
const currentSessions = createMemo(() => projectSessions(currentProject()))
|
||||
|
||||
function navigateSessionByOffset(offset: number) {
|
||||
const projects = layout.projects.list()
|
||||
if (projects.length === 0) return
|
||||
|
||||
const currentDirectory = params.dir ? base64Decode(params.dir) : undefined
|
||||
const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1
|
||||
const project = currentProject()
|
||||
const projectIndex = project ? projects.findIndex((p) => p.worktree === project.worktree) : -1
|
||||
|
||||
if (projectIndex === -1) {
|
||||
const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
|
||||
@@ -312,14 +318,14 @@ export default function Layout(props: ParentProps) {
|
||||
const nextProject = projects[nextProjectIndex]
|
||||
if (!nextProject) return
|
||||
|
||||
const nextProjectSessions = projectSessions(nextProject.worktree)
|
||||
const nextProjectSessions = projectSessions(nextProject)
|
||||
if (nextProjectSessions.length === 0) {
|
||||
navigateToProject(nextProject.worktree)
|
||||
return
|
||||
}
|
||||
|
||||
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
|
||||
navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`)
|
||||
navigateToSession(targetSession)
|
||||
queueMicrotask(() => scrollToSession(targetSession.id))
|
||||
}
|
||||
|
||||
@@ -465,7 +471,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
function navigateToSession(session: Session | undefined) {
|
||||
if (!session) return
|
||||
navigate(`/${params.dir}/session/${session?.id}`)
|
||||
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
|
||||
layout.mobileSidebar.hide()
|
||||
}
|
||||
|
||||
@@ -514,7 +520,8 @@ export default function Layout(props: ParentProps) {
|
||||
const id = params.id
|
||||
setStore("lastSession", directory, id)
|
||||
notification.session.markViewed(id)
|
||||
untrack(() => layout.projects.expand(directory))
|
||||
const project = currentProject()
|
||||
untrack(() => layout.projects.expand(project?.worktree ?? directory))
|
||||
requestAnimationFrame(() => scrollToSession(id))
|
||||
})
|
||||
|
||||
|
||||
@@ -467,7 +467,10 @@ export default function Page() {
|
||||
},
|
||||
{
|
||||
id: "permissions.autoaccept",
|
||||
title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
|
||||
title:
|
||||
params.id && permission.isAutoAccepting(params.id, sdk.directory)
|
||||
? "Stop auto-accepting edits"
|
||||
: "Auto-accept edits",
|
||||
category: "Permissions",
|
||||
keybind: "mod+shift+a",
|
||||
disabled: !params.id || !permission.permissionsEnabled(),
|
||||
@@ -476,8 +479,10 @@ export default function Page() {
|
||||
if (!sessionID) return
|
||||
permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
showToast({
|
||||
title: permission.isAutoAccepting(sessionID) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
|
||||
description: permission.isAutoAccepting(sessionID)
|
||||
title: permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
? "Auto-accepting edits"
|
||||
: "Stopped auto-accepting edits",
|
||||
description: permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
? "Edit and write permissions will be automatically approved"
|
||||
: "Edit and write permissions will require approval",
|
||||
})
|
||||
|
||||
@@ -1,17 +1,235 @@
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createResource, type Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
||||
type InitType = Promise<string> | string | null
|
||||
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
|
||||
|
||||
export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> {
|
||||
const platform = usePlatform()
|
||||
const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
|
||||
type PersistTarget = {
|
||||
storage?: string
|
||||
key: string
|
||||
legacy?: string[]
|
||||
migrate?: (value: unknown) => unknown
|
||||
}
|
||||
|
||||
const LEGACY_STORAGE = "default.dat"
|
||||
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||
|
||||
function snapshot(value: unknown) {
|
||||
return JSON.parse(JSON.stringify(value)) as unknown
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function merge(defaults: unknown, value: unknown): unknown {
|
||||
if (value === undefined) return defaults
|
||||
if (value === null) return value
|
||||
|
||||
if (Array.isArray(defaults)) {
|
||||
if (Array.isArray(value)) return value
|
||||
return defaults
|
||||
}
|
||||
|
||||
if (isRecord(defaults)) {
|
||||
if (!isRecord(value)) return defaults
|
||||
|
||||
const result: Record<string, unknown> = { ...defaults }
|
||||
for (const key of Object.keys(value)) {
|
||||
if (key in defaults) {
|
||||
result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
|
||||
} else {
|
||||
result[key] = (value as Record<string, unknown>)[key]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function parse(value: string) {
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceStorage(dir: string) {
|
||||
const head = dir.slice(0, 12) || "workspace"
|
||||
const sum = checksum(dir) ?? "0"
|
||||
return `opencode.workspace.${head}.${sum}.dat`
|
||||
}
|
||||
|
||||
function localStorageWithPrefix(prefix: string): SyncStorage {
|
||||
const base = `${prefix}:`
|
||||
return {
|
||||
getItem: (key) => localStorage.getItem(base + key),
|
||||
setItem: (key, value) => localStorage.setItem(base + key, value),
|
||||
removeItem: (key) => localStorage.removeItem(base + key),
|
||||
}
|
||||
}
|
||||
|
||||
export const Persist = {
|
||||
global(key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: GLOBAL_STORAGE, key, legacy }
|
||||
},
|
||||
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
|
||||
},
|
||||
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
|
||||
},
|
||||
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
|
||||
if (session) return Persist.session(dir, session, key, legacy)
|
||||
return Persist.workspace(dir, key, legacy)
|
||||
},
|
||||
}
|
||||
|
||||
export function removePersisted(target: { storage?: string; key: string }) {
|
||||
const platform = usePlatform()
|
||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||
|
||||
if (isDesktop) {
|
||||
return platform.storage?.(target.storage)?.removeItem(target.key)
|
||||
}
|
||||
|
||||
if (!target.storage) {
|
||||
localStorage.removeItem(target.key)
|
||||
return
|
||||
}
|
||||
|
||||
localStorageWithPrefix(target.storage).removeItem(target.key)
|
||||
}
|
||||
|
||||
export function persisted<T>(
|
||||
target: string | PersistTarget,
|
||||
store: [Store<T>, SetStoreFunction<T>],
|
||||
): PersistedWithReady<T> {
|
||||
const platform = usePlatform()
|
||||
const config: PersistTarget = typeof target === "string" ? { key: target } : target
|
||||
|
||||
const defaults = snapshot(store[0])
|
||||
const legacy = config.legacy ?? []
|
||||
|
||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||
|
||||
const currentStorage = (() => {
|
||||
if (isDesktop) return platform.storage?.(config.storage)
|
||||
if (!config.storage) return localStorage
|
||||
return localStorageWithPrefix(config.storage)
|
||||
})()
|
||||
|
||||
const legacyStorage = (() => {
|
||||
if (!isDesktop) return localStorage
|
||||
if (!config.storage) return platform.storage?.()
|
||||
return platform.storage?.(LEGACY_STORAGE)
|
||||
})()
|
||||
|
||||
const storage = (() => {
|
||||
if (!isDesktop) {
|
||||
const current = currentStorage as SyncStorage
|
||||
const legacyStore = legacyStorage as SyncStorage
|
||||
|
||||
const api: SyncStorage = {
|
||||
getItem: (key) => {
|
||||
const raw = current.getItem(key)
|
||||
if (raw !== null) {
|
||||
const parsed = parse(raw)
|
||||
if (parsed === undefined) return raw
|
||||
|
||||
const migrated = config.migrate ? config.migrate(parsed) : parsed
|
||||
const merged = merge(defaults, migrated)
|
||||
const next = JSON.stringify(merged)
|
||||
if (raw !== next) current.setItem(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
for (const legacyKey of legacy) {
|
||||
const legacyRaw = legacyStore.getItem(legacyKey)
|
||||
if (legacyRaw === null) continue
|
||||
|
||||
current.setItem(key, legacyRaw)
|
||||
legacyStore.removeItem(legacyKey)
|
||||
|
||||
const parsed = parse(legacyRaw)
|
||||
if (parsed === undefined) return legacyRaw
|
||||
|
||||
const migrated = config.migrate ? config.migrate(parsed) : parsed
|
||||
const merged = merge(defaults, migrated)
|
||||
const next = JSON.stringify(merged)
|
||||
if (legacyRaw !== next) current.setItem(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
current.setItem(key, value)
|
||||
},
|
||||
removeItem: (key) => {
|
||||
current.removeItem(key)
|
||||
},
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
const current = currentStorage as AsyncStorage
|
||||
const legacyStore = legacyStorage as AsyncStorage | undefined
|
||||
|
||||
const api: AsyncStorage = {
|
||||
getItem: async (key) => {
|
||||
const raw = await current.getItem(key)
|
||||
if (raw !== null) {
|
||||
const parsed = parse(raw)
|
||||
if (parsed === undefined) return raw
|
||||
|
||||
const migrated = config.migrate ? config.migrate(parsed) : parsed
|
||||
const merged = merge(defaults, migrated)
|
||||
const next = JSON.stringify(merged)
|
||||
if (raw !== next) await current.setItem(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
if (!legacyStore) return null
|
||||
|
||||
for (const legacyKey of legacy) {
|
||||
const legacyRaw = await legacyStore.getItem(legacyKey)
|
||||
if (legacyRaw === null) continue
|
||||
|
||||
await current.setItem(key, legacyRaw)
|
||||
await legacyStore.removeItem(legacyKey)
|
||||
|
||||
const parsed = parse(legacyRaw)
|
||||
if (parsed === undefined) return legacyRaw
|
||||
|
||||
const migrated = config.migrate ? config.migrate(parsed) : parsed
|
||||
const merged = merge(defaults, migrated)
|
||||
const next = JSON.stringify(merged)
|
||||
if (legacyRaw !== next) await current.setItem(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
await current.setItem(key, value)
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
await current.removeItem(key)
|
||||
},
|
||||
}
|
||||
|
||||
return api
|
||||
})()
|
||||
|
||||
const [state, setState, init] = makePersisted(store, { name: config.key, storage })
|
||||
|
||||
// Create a resource that resolves when the store is initialized
|
||||
// This integrates with Suspense and provides a ready signal
|
||||
const isAsync = init instanceof Promise
|
||||
const [ready] = createResource(
|
||||
() => init,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
@@ -146,6 +147,242 @@ export async function POST(input: APIEvent) {
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
})
|
||||
}
|
||||
if (body.type === "invoice.payment_succeeded" && body.data.object.billing_reason === "subscription_cycle") {
|
||||
const invoiceID = body.data.object.id as string
|
||||
const amountInCents = body.data.object.amount_paid
|
||||
const customerID = body.data.object.customer as string
|
||||
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
|
||||
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
|
||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||
expand: ["payments"],
|
||||
})
|
||||
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||
if (!paymentID) 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 for customer")
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx.insert(PaymentTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("payment"),
|
||||
amount: centsToMicroCents(amountInCents),
|
||||
paymentID,
|
||||
invoiceID,
|
||||
customerID,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (body.type === "customer.subscription.created") {
|
||||
const data = {
|
||||
id: "evt_1Smq802SrMQ2Fneksse5FMNV",
|
||||
object: "event",
|
||||
api_version: "2025-07-30.basil",
|
||||
created: 1767766916,
|
||||
data: {
|
||||
object: {
|
||||
id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||
object: "subscription",
|
||||
application: null,
|
||||
application_fee_percent: null,
|
||||
automatic_tax: {
|
||||
disabled_reason: null,
|
||||
enabled: false,
|
||||
liability: null,
|
||||
},
|
||||
billing_cycle_anchor: 1770445200,
|
||||
billing_cycle_anchor_config: null,
|
||||
billing_mode: {
|
||||
flexible: {
|
||||
proration_discounts: "included",
|
||||
},
|
||||
type: "flexible",
|
||||
updated_at: 1770445200,
|
||||
},
|
||||
billing_thresholds: null,
|
||||
cancel_at: null,
|
||||
cancel_at_period_end: false,
|
||||
canceled_at: null,
|
||||
cancellation_details: {
|
||||
comment: null,
|
||||
feedback: null,
|
||||
reason: null,
|
||||
},
|
||||
collection_method: "charge_automatically",
|
||||
created: 1770445200,
|
||||
currency: "usd",
|
||||
customer: "cus_TkKmZZvysJ2wej",
|
||||
customer_account: null,
|
||||
days_until_due: null,
|
||||
default_payment_method: null,
|
||||
default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
|
||||
default_tax_rates: [],
|
||||
description: null,
|
||||
discounts: [],
|
||||
ended_at: null,
|
||||
invoice_settings: {
|
||||
account_tax_ids: null,
|
||||
issuer: {
|
||||
type: "self",
|
||||
},
|
||||
},
|
||||
items: {
|
||||
object: "list",
|
||||
data: [
|
||||
{
|
||||
id: "si_TkKnBKXFX76t0O",
|
||||
object: "subscription_item",
|
||||
billing_thresholds: null,
|
||||
created: 1770445200,
|
||||
current_period_end: 1772864400,
|
||||
current_period_start: 1770445200,
|
||||
discounts: [],
|
||||
metadata: {},
|
||||
plan: {
|
||||
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||
object: "plan",
|
||||
active: true,
|
||||
amount: 20000,
|
||||
amount_decimal: "20000",
|
||||
billing_scheme: "per_unit",
|
||||
created: 1767725082,
|
||||
currency: "usd",
|
||||
interval: "month",
|
||||
interval_count: 1,
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
meter: null,
|
||||
nickname: null,
|
||||
product: "prod_Tk9LjWT1n0DgYm",
|
||||
tiers_mode: null,
|
||||
transform_usage: null,
|
||||
trial_period_days: null,
|
||||
usage_type: "licensed",
|
||||
},
|
||||
price: {
|
||||
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||
object: "price",
|
||||
active: true,
|
||||
billing_scheme: "per_unit",
|
||||
created: 1767725082,
|
||||
currency: "usd",
|
||||
custom_unit_amount: null,
|
||||
livemode: false,
|
||||
lookup_key: null,
|
||||
metadata: {},
|
||||
nickname: null,
|
||||
product: "prod_Tk9LjWT1n0DgYm",
|
||||
recurring: {
|
||||
interval: "month",
|
||||
interval_count: 1,
|
||||
meter: null,
|
||||
trial_period_days: null,
|
||||
usage_type: "licensed",
|
||||
},
|
||||
tax_behavior: "unspecified",
|
||||
tiers_mode: null,
|
||||
transform_quantity: null,
|
||||
type: "recurring",
|
||||
unit_amount: 20000,
|
||||
unit_amount_decimal: "20000",
|
||||
},
|
||||
quantity: 1,
|
||||
subscription: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||
tax_rates: [],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
total_count: 1,
|
||||
url: "/v1/subscription_items?subscription=sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||
},
|
||||
latest_invoice: "in_1Smq7x2SrMQ2FnekSJesfPwE",
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
next_pending_invoice_item_invoice: null,
|
||||
on_behalf_of: null,
|
||||
pause_collection: null,
|
||||
payment_settings: {
|
||||
payment_method_options: null,
|
||||
payment_method_types: null,
|
||||
save_default_payment_method: "off",
|
||||
},
|
||||
pending_invoice_item_interval: null,
|
||||
pending_setup_intent: null,
|
||||
pending_update: null,
|
||||
plan: {
|
||||
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||
object: "plan",
|
||||
active: true,
|
||||
amount: 20000,
|
||||
amount_decimal: "20000",
|
||||
billing_scheme: "per_unit",
|
||||
created: 1767725082,
|
||||
currency: "usd",
|
||||
interval: "month",
|
||||
interval_count: 1,
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
meter: null,
|
||||
nickname: null,
|
||||
product: "prod_Tk9LjWT1n0DgYm",
|
||||
tiers_mode: null,
|
||||
transform_usage: null,
|
||||
trial_period_days: null,
|
||||
usage_type: "licensed",
|
||||
},
|
||||
quantity: 1,
|
||||
schedule: null,
|
||||
start_date: 1770445200,
|
||||
status: "active",
|
||||
test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
|
||||
transfer_data: null,
|
||||
trial_end: null,
|
||||
trial_settings: {
|
||||
end_behavior: {
|
||||
missing_payment_method: "create_invoice",
|
||||
},
|
||||
},
|
||||
trial_start: null,
|
||||
},
|
||||
},
|
||||
livemode: false,
|
||||
pending_webhooks: 0,
|
||||
request: {
|
||||
id: "req_6YO9stvB155WJD",
|
||||
idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
|
||||
},
|
||||
type: "customer.subscription.created",
|
||||
}
|
||||
}
|
||||
if (body.type === "customer.subscription.deleted") {
|
||||
const subscriptionID = body.data.object.id
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
|
||||
const workspaceID = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.subscriptionID, subscriptionID))
|
||||
.then((rows) => rows[0]?.workspaceID),
|
||||
)
|
||||
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID))
|
||||
})
|
||||
}
|
||||
})()
|
||||
.then((message) => {
|
||||
return Response.json({ message: message ?? "done" }, { status: 200 })
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.root {
|
||||
[data-slot="title-row"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { action, useParams, useAction, useSubmission, json } from "@solidjs/router"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { queryBillingInfo } from "../../common"
|
||||
import styles from "./black-section.module.css"
|
||||
|
||||
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||
"use server"
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Billing.generateSessionUrl({ returnUrl })
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({
|
||||
error: e.message as string,
|
||||
data: undefined,
|
||||
})),
|
||||
workspaceID,
|
||||
),
|
||||
{ revalidate: queryBillingInfo.key },
|
||||
)
|
||||
}, "sessionUrl")
|
||||
|
||||
export function BlackSection() {
|
||||
const params = useParams()
|
||||
const sessionAction = useAction(createSessionUrl)
|
||||
const sessionSubmission = useSubmission(createSessionUrl)
|
||||
const [store, setStore] = createStore({
|
||||
sessionRedirecting: false,
|
||||
})
|
||||
|
||||
async function onClickSession() {
|
||||
const result = await sessionAction(params.id!, window.location.href)
|
||||
if (result.data) {
|
||||
setStore("sessionRedirecting", true)
|
||||
window.location.href = result.data
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Subscription</h2>
|
||||
<div data-slot="title-row">
|
||||
<p>You are subscribed to OpenCode Black for $200 per month.</p>
|
||||
<button
|
||||
data-color="primary"
|
||||
disabled={sessionSubmission.pending || store.sessionRedirecting}
|
||||
onClick={onClickSession}
|
||||
>
|
||||
{sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage Subscription"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -2,19 +2,23 @@ import { MonthlyLimitSection } from "./monthly-limit-section"
|
||||
import { BillingSection } from "./billing-section"
|
||||
import { ReloadSection } from "./reload-section"
|
||||
import { PaymentSection } from "./payment-section"
|
||||
import { BlackSection } from "./black-section"
|
||||
import { Show } from "solid-js"
|
||||
import { createAsync, useParams } from "@solidjs/router"
|
||||
import { queryBillingInfo, querySessionInfo } from "../../common"
|
||||
|
||||
export default function () {
|
||||
const params = useParams()
|
||||
const userInfo = createAsync(() => querySessionInfo(params.id!))
|
||||
const sessionInfo = createAsync(() => querySessionInfo(params.id!))
|
||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
||||
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<div data-slot="sections">
|
||||
<Show when={userInfo()?.isAdmin}>
|
||||
<Show when={sessionInfo()?.isAdmin}>
|
||||
<Show when={sessionInfo()?.isBeta && billingInfo()?.subscriptionID}>
|
||||
<BlackSection />
|
||||
</Show>
|
||||
<BillingSection />
|
||||
<Show when={billingInfo()?.customerID}>
|
||||
<ReloadSection />
|
||||
|
||||
@@ -169,7 +169,9 @@ export function UsageSection() {
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
|
||||
<td data-slot="usage-cost">
|
||||
${usage.enrichment?.plan === "sub" ? "0.0000" : ((usage.cost ?? 0) / 100000000).toFixed(4)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export class AuthError extends Error {}
|
||||
export class CreditsError extends Error {}
|
||||
export class MonthlyLimitError extends Error {}
|
||||
export class SubscriptionError extends Error {
|
||||
retryAfter?: number
|
||||
constructor(message: string, retryAfter?: number) {
|
||||
super(message)
|
||||
this.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
export class UserLimitError extends Error {}
|
||||
export class ModelError extends Error {}
|
||||
export class RateLimitError extends Error {}
|
||||
|
||||
@@ -8,11 +8,20 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
import { BlackData } from "@opencode-ai/console-core/black.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
||||
import { logger } from "./logger"
|
||||
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
|
||||
import {
|
||||
AuthError,
|
||||
CreditsError,
|
||||
MonthlyLimitError,
|
||||
SubscriptionError,
|
||||
UserLimitError,
|
||||
ModelError,
|
||||
RateLimitError,
|
||||
} from "./error"
|
||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
||||
import { anthropicHelper } from "./provider/anthropic"
|
||||
import { googleHelper } from "./provider/google"
|
||||
@@ -73,9 +82,9 @@ export async function handler(
|
||||
await rateLimiter?.check()
|
||||
const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId)
|
||||
const stickyProvider = await stickyTracker?.get()
|
||||
const authInfo = await authenticate(modelInfo)
|
||||
|
||||
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
||||
const authInfo = await authenticate(modelInfo)
|
||||
const providerInfo = selectProvider(
|
||||
zenData,
|
||||
authInfo,
|
||||
@@ -135,10 +144,10 @@ export async function handler(
|
||||
})
|
||||
}
|
||||
|
||||
return { providerInfo, authInfo, reqBody, res, startTimestamp }
|
||||
return { providerInfo, reqBody, res, startTimestamp }
|
||||
}
|
||||
|
||||
const { providerInfo, authInfo, reqBody, res, startTimestamp } = await retriableRequest()
|
||||
const { providerInfo, reqBody, res, startTimestamp } = await retriableRequest()
|
||||
|
||||
// Store model request
|
||||
dataDumper?.provideModel(providerInfo.storeModel)
|
||||
@@ -279,14 +288,19 @@ export async function handler(
|
||||
{ status: 401 },
|
||||
)
|
||||
|
||||
if (error instanceof RateLimitError)
|
||||
if (error instanceof RateLimitError || error instanceof SubscriptionError) {
|
||||
const headers = new Headers()
|
||||
if (error instanceof SubscriptionError && error.retryAfter) {
|
||||
headers.set("retry-after", String(error.retryAfter))
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: { type: error.constructor.name, message: error.message },
|
||||
}),
|
||||
{ status: 429 },
|
||||
{ status: 429, headers },
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -400,6 +414,13 @@ export async function handler(
|
||||
monthlyUsage: UserTable.monthlyUsage,
|
||||
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
||||
},
|
||||
subscription: {
|
||||
timeSubscribed: UserTable.timeSubscribed,
|
||||
subIntervalUsage: UserTable.subIntervalUsage,
|
||||
subMonthlyUsage: UserTable.subMonthlyUsage,
|
||||
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated,
|
||||
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated,
|
||||
},
|
||||
provider: {
|
||||
credentials: ProviderTable.credentials,
|
||||
},
|
||||
@@ -427,6 +448,7 @@ export async function handler(
|
||||
logger.metric({
|
||||
api_key: data.apiKey,
|
||||
workspace: data.workspaceID,
|
||||
isSubscription: data.subscription.timeSubscribed ? true : false,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -434,6 +456,7 @@ export async function handler(
|
||||
workspaceID: data.workspaceID,
|
||||
billing: data.billing,
|
||||
user: data.user,
|
||||
subscription: data.subscription.timeSubscribed ? data.subscription : undefined,
|
||||
provider: data.provider,
|
||||
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
||||
isDisabled: !!data.timeDisabled,
|
||||
@@ -446,6 +469,64 @@ export async function handler(
|
||||
if (authInfo.isFree) return
|
||||
if (modelInfo.allowAnonymous) return
|
||||
|
||||
// Validate subscription billing
|
||||
if (authInfo.subscription) {
|
||||
const black = BlackData.get()
|
||||
const sub = authInfo.subscription
|
||||
const now = new Date()
|
||||
|
||||
const formatRetryTime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.ceil((seconds % 3600) / 60)
|
||||
if (hours >= 1) return `${hours}hr ${minutes}min`
|
||||
return `${minutes}min`
|
||||
}
|
||||
|
||||
// Check monthly limit (based on subscription billing cycle)
|
||||
if (
|
||||
sub.subMonthlyUsage &&
|
||||
sub.timeSubMonthlyUsageUpdated &&
|
||||
sub.subMonthlyUsage >= centsToMicroCents(black.monthlyLimit * 100)
|
||||
) {
|
||||
const subscribeDay = sub.timeSubscribed!.getUTCDate()
|
||||
const cycleStart = new Date(
|
||||
Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
|
||||
subscribeDay,
|
||||
),
|
||||
)
|
||||
const cycleEnd = new Date(Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay))
|
||||
if (sub.timeSubMonthlyUsageUpdated >= cycleStart && sub.timeSubMonthlyUsageUpdated < cycleEnd) {
|
||||
const retryAfter = Math.ceil((cycleEnd.getTime() - now.getTime()) / 1000)
|
||||
throw new SubscriptionError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
||||
retryAfter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check interval limit
|
||||
const intervalMs = black.intervalLength * 3600 * 1000
|
||||
if (sub.subIntervalUsage && sub.timeSubIntervalUsageUpdated) {
|
||||
const currentInterval = Math.floor(now.getTime() / intervalMs)
|
||||
const usageInterval = Math.floor(sub.timeSubIntervalUsageUpdated.getTime() / intervalMs)
|
||||
if (currentInterval === usageInterval && sub.subIntervalUsage >= centsToMicroCents(black.intervalLimit * 100)) {
|
||||
const nextInterval = (currentInterval + 1) * intervalMs
|
||||
const retryAfter = Math.ceil((nextInterval - now.getTime()) / 1000)
|
||||
throw new SubscriptionError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
||||
retryAfter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Validate pay as you go billing
|
||||
const billing = authInfo.billing
|
||||
if (!billing.paymentMethodID)
|
||||
throw new CreditsError(
|
||||
@@ -463,29 +544,25 @@ export async function handler(
|
||||
billing.monthlyLimit &&
|
||||
billing.monthlyUsage &&
|
||||
billing.timeMonthlyUsageUpdated &&
|
||||
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
|
||||
) {
|
||||
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
|
||||
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
|
||||
if (currentYear === dateYear && currentMonth === dateMonth)
|
||||
throw new MonthlyLimitError(
|
||||
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
||||
)
|
||||
}
|
||||
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100) &&
|
||||
currentYear === billing.timeMonthlyUsageUpdated.getUTCFullYear() &&
|
||||
currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
|
||||
)
|
||||
throw new MonthlyLimitError(
|
||||
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
||||
)
|
||||
|
||||
if (
|
||||
authInfo.user.monthlyLimit &&
|
||||
authInfo.user.monthlyUsage &&
|
||||
authInfo.user.timeMonthlyUsageUpdated &&
|
||||
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100)
|
||||
) {
|
||||
const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear()
|
||||
const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
|
||||
if (currentYear === dateYear && currentMonth === dateMonth)
|
||||
throw new UserLimitError(
|
||||
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
|
||||
)
|
||||
}
|
||||
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100) &&
|
||||
currentYear === authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear() &&
|
||||
currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
|
||||
)
|
||||
throw new UserLimitError(
|
||||
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
|
||||
)
|
||||
}
|
||||
|
||||
function validateModelSettings(authInfo: AuthInfo) {
|
||||
@@ -560,7 +637,7 @@ export async function handler(
|
||||
|
||||
if (!authInfo) return
|
||||
|
||||
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
||||
const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
||||
await Database.use((db) =>
|
||||
Promise.all([
|
||||
db.insert(UsageTable).values({
|
||||
@@ -576,36 +653,77 @@ export async function handler(
|
||||
cacheWrite1hTokens,
|
||||
cost,
|
||||
keyID: authInfo.apiKeyId,
|
||||
enrichment: authInfo.subscription ? { plan: "sub" } : undefined,
|
||||
}),
|
||||
db
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} - ${cost}`,
|
||||
monthlyUsage: sql`
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||
...(authInfo.subscription
|
||||
? (() => {
|
||||
const now = new Date()
|
||||
const subscribeDay = authInfo.subscription.timeSubscribed!.getUTCDate()
|
||||
const cycleStart = new Date(
|
||||
Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
|
||||
subscribeDay,
|
||||
),
|
||||
)
|
||||
const cycleEnd = new Date(
|
||||
Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay),
|
||||
)
|
||||
return [
|
||||
db
|
||||
.update(UserTable)
|
||||
.set({
|
||||
subMonthlyUsage: sql`
|
||||
CASE
|
||||
WHEN ${UserTable.timeSubMonthlyUsageUpdated} >= ${cycleStart} AND ${UserTable.timeSubMonthlyUsageUpdated} < ${cycleEnd} THEN ${UserTable.subMonthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeSubMonthlyUsageUpdated: sql`now()`,
|
||||
subIntervalUsage: sql`
|
||||
CASE
|
||||
WHEN FLOOR(UNIX_TIMESTAMP(${UserTable.timeSubIntervalUsageUpdated}) / (${BlackData.get().intervalLength} * 3600)) = FLOOR(UNIX_TIMESTAMP(now()) / (${BlackData.get().intervalLength} * 3600)) THEN ${UserTable.subIntervalUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeSubIntervalUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||
]
|
||||
})()
|
||||
: [
|
||||
db
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: authInfo.isFree
|
||||
? sql`${BillingTable.balance} - ${0}`
|
||||
: sql`${BillingTable.balance} - ${cost}`,
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
|
||||
db
|
||||
.update(UserTable)
|
||||
.set({
|
||||
monthlyUsage: sql`
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
|
||||
db
|
||||
.update(UserTable)
|
||||
.set({
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||
db
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -616,6 +734,7 @@ export async function handler(
|
||||
if (!authInfo) return
|
||||
if (authInfo.isFree) return
|
||||
if (authInfo.provider?.credentials) return
|
||||
if (authInfo.subscription) return
|
||||
|
||||
if (!costInfo) return
|
||||
|
||||
|
||||
7
packages/console/core/migrations/0042_flat_nightmare.sql
Normal file
7
packages/console/core/migrations/0042_flat_nightmare.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE `billing` ADD `subscription_id` varchar(28);--> statement-breakpoint
|
||||
ALTER TABLE `usage` ADD `data` json;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `time_subscribed` timestamp(3);--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `sub_recent_usage` bigint;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `sub_monthly_usage` bigint;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `sub_time_recent_usage_updated` timestamp(3);--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `sub_time_monthly_usage_updated` timestamp(3);
|
||||
2
packages/console/core/migrations/0043_lame_calypso.sql
Normal file
2
packages/console/core/migrations/0043_lame_calypso.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `user` RENAME COLUMN `sub_recent_usage` TO `sub_interval_usage`;--> statement-breakpoint
|
||||
ALTER TABLE `user` RENAME COLUMN `sub_time_recent_usage_updated` TO `sub_time_interval_usage_updated`;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `usage` RENAME COLUMN `data` TO `enrichment`;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `billing` ADD CONSTRAINT `global_subscription_id` UNIQUE(`subscription_id`);
|
||||
1144
packages/console/core/migrations/meta/0042_snapshot.json
Normal file
1144
packages/console/core/migrations/meta/0042_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1147
packages/console/core/migrations/meta/0043_snapshot.json
Normal file
1147
packages/console/core/migrations/meta/0043_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1146
packages/console/core/migrations/meta/0044_snapshot.json
Normal file
1146
packages/console/core/migrations/meta/0044_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1149
packages/console/core/migrations/meta/0045_snapshot.json
Normal file
1149
packages/console/core/migrations/meta/0045_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -295,6 +295,34 @@
|
||||
"when": 1767732559197,
|
||||
"tag": "0041_odd_misty_knight",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 42,
|
||||
"version": "5",
|
||||
"when": 1767744077346,
|
||||
"tag": "0042_flat_nightmare",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "5",
|
||||
"when": 1767752636118,
|
||||
"tag": "0043_lame_calypso",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "5",
|
||||
"when": 1767759322451,
|
||||
"tag": "0044_tiny_captain_midlands",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 45,
|
||||
"version": "5",
|
||||
"when": 1767765497502,
|
||||
"tag": "0045_cuddly_diamondback",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -32,6 +32,9 @@
|
||||
"promote-models-to-dev": "script/promote-models.ts dev",
|
||||
"promote-models-to-prod": "script/promote-models.ts production",
|
||||
"pull-models-from-dev": "script/pull-models.ts dev",
|
||||
"update-black": "script/update-black.ts",
|
||||
"promote-black-to-dev": "script/promote-black.ts dev",
|
||||
"promote-black-to-prod": "script/promote-black.ts production",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
166
packages/console/core/script/onboard-zen-black.ts
Normal file
166
packages/console/core/script/onboard-zen-black.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { Database, eq, and, sql } from "../src/drizzle/index.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js"
|
||||
import { Identifier } from "../src/identifier.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
|
||||
const workspaceID = process.argv[2]
|
||||
const email = process.argv[3]
|
||||
|
||||
if (!workspaceID || !email) {
|
||||
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Look up the Stripe customer by email
|
||||
const customers = await Billing.stripe().customers.list({ email, limit: 1 })
|
||||
const customer = customers.data[0]
|
||||
if (!customer) {
|
||||
console.error(`Error: No Stripe customer found for email ${email}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const customerID = customer.id
|
||||
|
||||
// Get the subscription id
|
||||
const subscriptions = await Billing.stripe().subscriptions.list({ customer: customerID, limit: 1 })
|
||||
const subscription = subscriptions.data[0]
|
||||
if (!subscription) {
|
||||
console.error(`Error: Customer ${customerID} does not have a subscription`)
|
||||
process.exit(1)
|
||||
}
|
||||
const subscriptionID = subscription.id
|
||||
|
||||
// Validate the subscription is $200
|
||||
const amountInCents = subscription.items.data[0]?.price.unit_amount ?? 0
|
||||
if (amountInCents !== 20000) {
|
||||
console.error(`Error: Subscription amount is $${amountInCents / 100}, expected $200`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Check if subscription is already tied to another workspace
|
||||
const existingSubscription = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.subscriptionID, subscriptionID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (existingSubscription) {
|
||||
console.error(
|
||||
`Error: Subscription ${subscriptionID} is already tied to workspace ${existingSubscription.workspaceID}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Look up the workspace billing and check if it already has a customer id or subscription
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ customerID: BillingTable.customerID, subscriptionID: BillingTable.subscriptionID })
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (billing?.subscriptionID) {
|
||||
console.error(`Error: Workspace ${workspaceID} already has a subscription: ${billing.subscriptionID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (billing?.customerID) {
|
||||
console.warn(
|
||||
`Warning: Workspace ${workspaceID} already has a customer id: ${billing.customerID}, replacing with ${customerID}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Get the latest invoice and payment from the subscription
|
||||
const invoices = await Billing.stripe().invoices.list({
|
||||
subscription: subscriptionID,
|
||||
limit: 1,
|
||||
expand: ["data.payments"],
|
||||
})
|
||||
const invoice = invoices.data[0]
|
||||
const invoiceID = invoice?.id
|
||||
const paymentID = invoice?.payments?.data[0]?.payment.payment_intent as string | undefined
|
||||
|
||||
// Get the default payment method from the customer
|
||||
const paymentMethodID = (customer.invoice_settings.default_payment_method ?? subscription.default_payment_method) as
|
||||
| string
|
||||
| null
|
||||
const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.retrieve(paymentMethodID) : null
|
||||
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
|
||||
const paymentMethodType = paymentMethod?.type ?? null
|
||||
|
||||
// Look up the user by email via AuthTable
|
||||
const auth = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ accountID: AuthTable.accountID })
|
||||
.from(AuthTable)
|
||||
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!auth) {
|
||||
console.error(`Error: No user found with email ${email}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Look up the user in the workspace
|
||||
const user = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ id: UserTable.id })
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, auth.accountID)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!user) {
|
||||
console.error(`Error: User with email ${email} is not a member of workspace ${workspaceID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Set workspaceID in Stripe customer metadata
|
||||
await Billing.stripe().customers.update(customerID, {
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
// Set customer id, subscription id, and payment method on workspace billing
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID,
|
||||
subscriptionID,
|
||||
paymentMethodID,
|
||||
paymentMethodLast4,
|
||||
paymentMethodType,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
// Set current time as timeSubscribed on user
|
||||
await tx
|
||||
.update(UserTable)
|
||||
.set({
|
||||
timeSubscribed: sql`now()`,
|
||||
})
|
||||
.where(eq(UserTable.id, user.id))
|
||||
|
||||
// Create a row in payments table
|
||||
await tx.insert(PaymentTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("payment"),
|
||||
amount: centsToMicroCents(amountInCents),
|
||||
customerID,
|
||||
invoiceID,
|
||||
paymentID,
|
||||
})
|
||||
})
|
||||
|
||||
console.log(`Successfully onboarded workspace ${workspaceID}`)
|
||||
console.log(` Customer ID: ${customerID}`)
|
||||
console.log(` Subscription ID: ${subscriptionID}`)
|
||||
console.log(
|
||||
` Payment Method: ${paymentMethodID ?? "(none)"} (${paymentMethodType ?? "unknown"} ending in ${paymentMethodLast4 ?? "????"})`,
|
||||
)
|
||||
console.log(` User ID: ${user.id}`)
|
||||
console.log(` Invoice ID: ${invoiceID ?? "(none)"}`)
|
||||
console.log(` Payment ID: ${paymentID ?? "(none)"}`)
|
||||
22
packages/console/core/script/promote-black.ts
Executable file
22
packages/console/core/script/promote-black.ts
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { BlackData } from "../src/black"
|
||||
|
||||
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 lines = ret.split("\n")
|
||||
const value = lines.find((line) => line.startsWith("ZEN_BLACK"))?.split("=")[1]
|
||||
if (!value) throw new Error("ZEN_BLACK not found")
|
||||
|
||||
// validate value
|
||||
BlackData.validate(JSON.parse(value))
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_BLACK ${value} --stage ${stage}`
|
||||
28
packages/console/core/script/update-black.ts
Executable file
28
packages/console/core/script/update-black.ts
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { BlackData } from "../src/black"
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const secrets = await $`bun sst secret list`.cwd(root).text()
|
||||
|
||||
// read the line starting with "ZEN_BLACK"
|
||||
const lines = secrets.split("\n")
|
||||
const oldValue = lines.find((line) => line.startsWith("ZEN_BLACK"))?.split("=")[1]
|
||||
if (!oldValue) throw new Error("ZEN_BLACK not found")
|
||||
|
||||
// store the prettified json to a temp file
|
||||
const filename = `black-${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.stringify(JSON.parse(await tempFile.text()))
|
||||
BlackData.validate(JSON.parse(newValue))
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_BLACK ${newValue}`
|
||||
@@ -27,6 +27,7 @@ export namespace Billing {
|
||||
tx
|
||||
.select({
|
||||
customerID: BillingTable.customerID,
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
paymentMethodID: BillingTable.paymentMethodID,
|
||||
paymentMethodType: BillingTable.paymentMethodType,
|
||||
paymentMethodLast4: BillingTable.paymentMethodLast4,
|
||||
|
||||
20
packages/console/core/src/black.ts
Normal file
20
packages/console/core/src/black.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
export namespace BlackData {
|
||||
const Schema = z.object({
|
||||
monthlyLimit: z.number().int(),
|
||||
intervalLimit: z.number().int(),
|
||||
intervalLength: z.number().int(),
|
||||
})
|
||||
|
||||
export const validate = fn(Schema, (input) => {
|
||||
return input
|
||||
})
|
||||
|
||||
export const get = fn(z.void(), () => {
|
||||
const json = JSON.parse(Resource.ZEN_BLACK.value)
|
||||
return Schema.parse(json)
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bigint, boolean, index, int, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { bigint, boolean, index, int, json, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
@@ -21,8 +21,13 @@ export const BillingTable = mysqlTable(
|
||||
reloadError: varchar("reload_error", { length: 255 }),
|
||||
timeReloadError: utc("time_reload_error"),
|
||||
timeReloadLockedTill: utc("time_reload_locked_till"),
|
||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("global_customer_id").on(table.customerID)],
|
||||
(table) => [
|
||||
...workspaceIndexes(table),
|
||||
uniqueIndex("global_customer_id").on(table.customerID),
|
||||
uniqueIndex("global_subscription_id").on(table.subscriptionID),
|
||||
],
|
||||
)
|
||||
|
||||
export const PaymentTable = mysqlTable(
|
||||
@@ -54,6 +59,9 @@ export const UsageTable = mysqlTable(
|
||||
cacheWrite1hTokens: int("cache_write_1h_tokens"),
|
||||
cost: bigint("cost", { mode: "number" }).notNull(),
|
||||
keyID: ulid("key_id"),
|
||||
enrichment: json("enrichment").$type<{
|
||||
plan: "sub"
|
||||
}>(),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
|
||||
)
|
||||
|
||||
@@ -18,6 +18,12 @@ export const UserTable = mysqlTable(
|
||||
monthlyLimit: int("monthly_limit"),
|
||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
|
||||
// subscription
|
||||
timeSubscribed: utc("time_subscribed"),
|
||||
subIntervalUsage: bigint("sub_interval_usage", { mode: "number" }),
|
||||
subMonthlyUsage: bigint("sub_monthly_usage", { mode: "number" }),
|
||||
timeSubIntervalUsageUpdated: utc("sub_time_interval_usage_updated"),
|
||||
timeSubMonthlyUsageUpdated: utc("sub_time_monthly_usage_updated"),
|
||||
},
|
||||
(table) => [
|
||||
...workspaceIndexes(table),
|
||||
|
||||
4
packages/console/core/sst-env.d.ts
vendored
4
packages/console/core/sst-env.d.ts
vendored
@@ -98,6 +98,10 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_BLACK": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
4
packages/console/function/sst-env.d.ts
vendored
4
packages/console/function/sst-env.d.ts
vendored
@@ -98,6 +98,10 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_BLACK": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
4
packages/console/resource/sst-env.d.ts
vendored
4
packages/console/resource/sst-env.d.ts
vendored
@@ -98,6 +98,10 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_BLACK": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
|
||||
18
packages/desktop/src-tauri/Cargo.lock
generated
18
packages/desktop/src-tauri/Cargo.lock
generated
@@ -1177,6 +1177,21 @@ dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
@@ -1184,6 +1199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1251,6 +1267,7 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
@@ -2775,6 +2792,7 @@ dependencies = [
|
||||
name = "opencode-desktop"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"gtk",
|
||||
"listeners",
|
||||
"semver",
|
||||
|
||||
@@ -36,6 +36,7 @@ serde_json = "1"
|
||||
tokio = "1.48.0"
|
||||
listeners = "0.3"
|
||||
tauri-plugin-os = "2"
|
||||
futures = "0.3.31"
|
||||
semver = "1.0.27"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
@@ -12,5 +12,19 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.addressbook</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.calendars</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.photos-library</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -2,6 +2,7 @@ mod cli;
|
||||
mod window_customizer;
|
||||
|
||||
use cli::{get_sidecar_path, install_cli, sync_cli};
|
||||
use futures::FutureExt;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
net::{SocketAddr, TcpListener},
|
||||
@@ -9,10 +10,9 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri::{
|
||||
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow,
|
||||
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
|
||||
WebviewWindow,
|
||||
};
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tokio::net::TcpSocket;
|
||||
@@ -20,7 +20,26 @@ use tokio::net::TcpSocket;
|
||||
use crate::window_customizer::PinchZoomDisablePlugin;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ServerState(Arc<Mutex<Option<CommandChild>>>);
|
||||
struct ServerState {
|
||||
child: Arc<Mutex<Option<CommandChild>>>,
|
||||
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
pub fn new(
|
||||
child: Option<CommandChild>,
|
||||
status: tokio::sync::oneshot::Receiver<Result<(), String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
status: status.shared(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_child(&self, child: Option<CommandChild>) {
|
||||
*self.child.lock().unwrap() = child;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LogState(Arc<Mutex<VecDeque<String>>>);
|
||||
@@ -35,7 +54,7 @@ fn kill_sidecar(app: AppHandle) {
|
||||
};
|
||||
|
||||
let Some(server_state) = server_state
|
||||
.0
|
||||
.child
|
||||
.lock()
|
||||
.expect("Failed to acquire mutex lock")
|
||||
.take()
|
||||
@@ -49,25 +68,6 @@ fn kill_sidecar(app: AppHandle) {
|
||||
println!("Killed server");
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn copy_logs_to_clipboard(app: AppHandle) -> Result<(), String> {
|
||||
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
|
||||
|
||||
let logs = log_state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Failed to acquire log lock")?;
|
||||
|
||||
let log_text = logs.iter().cloned().collect::<Vec<_>>().join("");
|
||||
|
||||
app.clipboard()
|
||||
.write_text(log_text)
|
||||
.map_err(|e| format!("Failed to copy to clipboard: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_logs(app: AppHandle) -> Result<String, String> {
|
||||
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
|
||||
|
||||
@@ -79,6 +79,15 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
|
||||
Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
|
||||
state
|
||||
.status
|
||||
.clone()
|
||||
.await
|
||||
.map_err(|_| "Failed to get server status".to_string())?
|
||||
}
|
||||
|
||||
fn get_sidecar_port() -> u32 {
|
||||
option_env!("OPENCODE_PORT")
|
||||
.map(|s| s.to_string())
|
||||
@@ -130,7 +139,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||
.args([
|
||||
"-il",
|
||||
"-c",
|
||||
&format!("{} serve --port={}", sidecar.display(), port),
|
||||
&format!("\"{}\" serve --port={}", sidecar.display(), port),
|
||||
])
|
||||
.spawn()
|
||||
.expect("Failed to spawn opencode")
|
||||
@@ -209,9 +218,8 @@ pub fn run() {
|
||||
.plugin(PinchZoomDisablePlugin)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
kill_sidecar,
|
||||
copy_logs_to_clipboard,
|
||||
get_logs,
|
||||
install_cli
|
||||
install_cli,
|
||||
ensure_server_started
|
||||
])
|
||||
.setup(move |app| {
|
||||
let app = app.handle().clone();
|
||||
@@ -219,94 +227,93 @@ pub fn run() {
|
||||
// Initialize log state
|
||||
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
|
||||
|
||||
// Get port and create window immediately for faster perceived startup
|
||||
let port = get_sidecar_port();
|
||||
|
||||
let primary_monitor = app.primary_monitor().ok().flatten();
|
||||
let size = primary_monitor
|
||||
.map(|m| m.size().to_logical(m.scale_factor()))
|
||||
.unwrap_or(LogicalSize::new(1920, 1080));
|
||||
|
||||
// Create window immediately with serverReady = false
|
||||
let mut window_builder =
|
||||
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
||||
.title("OpenCode")
|
||||
.inner_size(size.width as f64, size.height as f64)
|
||||
.decorations(true)
|
||||
.zoom_hotkeys_enabled(true)
|
||||
.disable_drag_drop_handler()
|
||||
.initialization_script(format!(
|
||||
r#"
|
||||
window.__OPENCODE__ ??= {{}};
|
||||
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
||||
window.__OPENCODE__.port = {port};
|
||||
"#
|
||||
));
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let port = get_sidecar_port();
|
||||
window_builder = window_builder
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||
.hidden_title(true);
|
||||
}
|
||||
|
||||
let should_spawn_sidecar = !is_server_running(port).await;
|
||||
let window = window_builder.build().expect("Failed to create window");
|
||||
|
||||
let child = if should_spawn_sidecar {
|
||||
let child = spawn_sidecar(&app, port);
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.manage(ServerState::new(None, rx));
|
||||
|
||||
let timestamp = Instant::now();
|
||||
loop {
|
||||
if timestamp.elapsed() > Duration::from_secs(7) {
|
||||
let res = app.dialog()
|
||||
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
|
||||
.title("Startup Failed")
|
||||
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
|
||||
.blocking_show_with_result();
|
||||
{
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let should_spawn_sidecar = !is_server_running(port).await;
|
||||
|
||||
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
|
||||
match copy_logs_to_clipboard(app.clone()).await {
|
||||
Ok(()) => println!("Logs copied to clipboard successfully"),
|
||||
Err(e) => println!("Failed to copy logs to clipboard: {}", e),
|
||||
}
|
||||
}
|
||||
let (child, res) = if should_spawn_sidecar {
|
||||
let child = spawn_sidecar(&app, port);
|
||||
|
||||
app.exit(1);
|
||||
let timestamp = Instant::now();
|
||||
let res = loop {
|
||||
if timestamp.elapsed() > Duration::from_secs(7) {
|
||||
break Err(format!(
|
||||
"Failed to spawn OpenCode Server. Logs:\n{}",
|
||||
get_logs(app.clone()).await.unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
if is_server_running(port).await {
|
||||
// give the server a little bit more time to warm up
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
if is_server_running(port).await {
|
||||
// give the server a little bit more time to warm up
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
break Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
println!("Server ready after {:?}", timestamp.elapsed());
|
||||
|
||||
println!("Server ready after {:?}", timestamp.elapsed());
|
||||
(Some(child), res)
|
||||
} else {
|
||||
(None, Ok(()))
|
||||
};
|
||||
|
||||
Some(child)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
app.state::<ServerState>().set_child(child);
|
||||
|
||||
let primary_monitor = app.primary_monitor().ok().flatten();
|
||||
let size = primary_monitor
|
||||
.map(|m| m.size().to_logical(m.scale_factor()))
|
||||
.unwrap_or(LogicalSize::new(1920, 1080));
|
||||
if res.is_ok() {
|
||||
let _ = window.eval("window.__OPENCODE__.serverReady = true;");
|
||||
}
|
||||
|
||||
let mut window_builder =
|
||||
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
||||
.title("OpenCode")
|
||||
.inner_size(size.width as f64, size.height as f64)
|
||||
.decorations(true)
|
||||
.zoom_hotkeys_enabled(true)
|
||||
.disable_drag_drop_handler()
|
||||
.initialization_script(format!(
|
||||
r#"
|
||||
window.__OPENCODE__ ??= {{}};
|
||||
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
||||
window.__OPENCODE__.port = {port};
|
||||
"#
|
||||
));
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
window_builder = window_builder
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||
.hidden_title(true);
|
||||
}
|
||||
|
||||
window_builder.build().expect("Failed to create window");
|
||||
|
||||
app.manage(ServerState(Arc::new(Mutex::new(child))));
|
||||
});
|
||||
let _ = tx.send(res);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = sync_cli(app) {
|
||||
eprintln!("Failed to sync CLI: {e}");
|
||||
}
|
||||
});
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = sync_cli(app) {
|
||||
eprintln!("Failed to sync CLI: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
// @refresh reload
|
||||
import { render } from "solid-js/web"
|
||||
import { App, PlatformProvider, Platform } from "@opencode-ai/app"
|
||||
import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
|
||||
import { open, save } from "@tauri-apps/plugin-dialog"
|
||||
import { open as shellOpen } from "@tauri-apps/plugin-shell"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
import { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
|
||||
import { Store } from "@tauri-apps/plugin-store"
|
||||
|
||||
import { UPDATER_ENABLED } from "./updater"
|
||||
import { createMenu } from "./menu"
|
||||
import { check, Update } from "@tauri-apps/plugin-updater"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
import { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
|
||||
import { Store } from "@tauri-apps/plugin-store"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Suspense, createResource, ParentProps } from "solid-js"
|
||||
|
||||
import { UPDATER_ENABLED } from "./updater"
|
||||
import { createMenu } from "./menu"
|
||||
import pkg from "../package.json"
|
||||
import { Show } from "solid-js"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
@@ -60,7 +63,7 @@ const platform: Platform = {
|
||||
void shellOpen(url).catch(() => undefined)
|
||||
},
|
||||
|
||||
storage: (name = "default.dat") => {
|
||||
storage: (() => {
|
||||
type StoreLike = {
|
||||
get(key: string): Promise<string | null | undefined>
|
||||
set(key: string, value: string): Promise<unknown>
|
||||
@@ -70,7 +73,13 @@ const platform: Platform = {
|
||||
length(): Promise<number>
|
||||
}
|
||||
|
||||
const memory = () => {
|
||||
const WRITE_DEBOUNCE_MS = 250
|
||||
|
||||
const storeCache = new Map<string, Promise<StoreLike>>()
|
||||
const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
|
||||
const memoryCache = new Map<string, StoreLike>()
|
||||
|
||||
const createMemoryStore = () => {
|
||||
const data = new Map<string, string>()
|
||||
const store: StoreLike = {
|
||||
get: async (key) => data.get(key),
|
||||
@@ -89,45 +98,108 @@ const platform: Platform = {
|
||||
return store
|
||||
}
|
||||
|
||||
const api: AsyncStorage & { _store: Promise<StoreLike> | null; _getStore: () => Promise<StoreLike> } = {
|
||||
_store: null,
|
||||
_getStore: async () => {
|
||||
if (api._store) return api._store
|
||||
api._store = Store.load(name).catch(() => memory())
|
||||
return api._store
|
||||
},
|
||||
getItem: async (key: string) => {
|
||||
const store = await api._getStore()
|
||||
const value = await store.get(key).catch(() => null)
|
||||
if (value === undefined) return null
|
||||
return value
|
||||
},
|
||||
setItem: async (key: string, value: string) => {
|
||||
const store = await api._getStore()
|
||||
await store.set(key, value).catch(() => undefined)
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
const store = await api._getStore()
|
||||
await store.delete(key).catch(() => undefined)
|
||||
},
|
||||
clear: async () => {
|
||||
const store = await api._getStore()
|
||||
await store.clear().catch(() => undefined)
|
||||
},
|
||||
key: async (index: number) => {
|
||||
const store = await api._getStore()
|
||||
return (await store.keys().catch(() => []))[index]
|
||||
},
|
||||
getLength: async () => {
|
||||
const store = await api._getStore()
|
||||
return await store.length().catch(() => 0)
|
||||
},
|
||||
get length() {
|
||||
return api.getLength()
|
||||
},
|
||||
const getStore = (name: string) => {
|
||||
const cached = storeCache.get(name)
|
||||
if (cached) return cached
|
||||
|
||||
const store = Store.load(name).catch(() => {
|
||||
const cached = memoryCache.get(name)
|
||||
if (cached) return cached
|
||||
|
||||
const memory = createMemoryStore()
|
||||
memoryCache.set(name, memory)
|
||||
return memory
|
||||
})
|
||||
|
||||
storeCache.set(name, store)
|
||||
return store
|
||||
}
|
||||
return api
|
||||
},
|
||||
|
||||
const createStorage = (name: string) => {
|
||||
const pending = new Map<string, string | null>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let flushing: Promise<void> | undefined
|
||||
|
||||
const flush = async () => {
|
||||
if (flushing) return flushing
|
||||
|
||||
flushing = (async () => {
|
||||
const store = await getStore(name)
|
||||
while (pending.size > 0) {
|
||||
const batch = Array.from(pending.entries())
|
||||
pending.clear()
|
||||
for (const [key, value] of batch) {
|
||||
if (value === null) {
|
||||
await store.delete(key).catch(() => undefined)
|
||||
} else {
|
||||
await store.set(key, value).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
flushing = undefined
|
||||
})
|
||||
|
||||
return flushing
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (timer) return
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined
|
||||
void flush()
|
||||
}, WRITE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
const api: AsyncStorage & { flush: () => Promise<void> } = {
|
||||
flush,
|
||||
getItem: async (key: string) => {
|
||||
const next = pending.get(key)
|
||||
if (next !== undefined) return next
|
||||
|
||||
const store = await getStore(name)
|
||||
const value = await store.get(key).catch(() => null)
|
||||
if (value === undefined) return null
|
||||
return value
|
||||
},
|
||||
setItem: async (key: string, value: string) => {
|
||||
pending.set(key, value)
|
||||
schedule()
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
pending.set(key, null)
|
||||
schedule()
|
||||
},
|
||||
clear: async () => {
|
||||
pending.clear()
|
||||
const store = await getStore(name)
|
||||
await store.clear().catch(() => undefined)
|
||||
},
|
||||
key: async (index: number) => {
|
||||
const store = await getStore(name)
|
||||
return (await store.keys().catch(() => []))[index]
|
||||
},
|
||||
getLength: async () => {
|
||||
const store = await getStore(name)
|
||||
return await store.length().catch(() => 0)
|
||||
},
|
||||
get length() {
|
||||
return api.getLength()
|
||||
},
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
return (name = "default.dat") => {
|
||||
const cached = apiCache.get(name)
|
||||
if (cached) return cached
|
||||
|
||||
const api = createStorage(name)
|
||||
apiCache.set(name, api)
|
||||
return api
|
||||
}
|
||||
})(),
|
||||
|
||||
checkUpdate: async () => {
|
||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
||||
@@ -200,7 +272,36 @@ render(() => {
|
||||
{ostype() === "macos" && (
|
||||
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
|
||||
)}
|
||||
<App />
|
||||
<AppBaseProviders>
|
||||
<ServerGate>
|
||||
<AppInterface />
|
||||
</ServerGate>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}, root!)
|
||||
|
||||
// Gate component that waits for the server to be ready
|
||||
function ServerGate(props: ParentProps) {
|
||||
const [status] = createResource(async () => {
|
||||
if (window.__OPENCODE__?.serverReady) return
|
||||
return await invoke("ensure_server_started")
|
||||
})
|
||||
|
||||
return (
|
||||
// Not using suspense as not all components are compatible with it (undefined refs)
|
||||
<Show
|
||||
when={status.state !== "pending"}
|
||||
fallback={
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Logo class="w-xl opacity-12 animate-pulse" />
|
||||
<div class="mt-8 text-14-regular text-text-weak">Starting server...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Trigger error boundary without rendering the returned value */}
|
||||
{(status(), null)}
|
||||
{props.children}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
4
packages/enterprise/sst-env.d.ts
vendored
4
packages/enterprise/sst-env.d.ts
vendored
@@ -98,6 +98,10 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_BLACK": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.4"
|
||||
version = "1.1.6"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
4
packages/function/sst-env.d.ts
vendored
4
packages/function/sst-env.d.ts
vendored
@@ -98,6 +98,10 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_BLACK": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -135,7 +135,7 @@ for (const item of targets) {
|
||||
autoloadPackageJson: true,
|
||||
target: name.replace(pkg.name, "bun") as any,
|
||||
outfile: `dist/${name}/bin/opencode`,
|
||||
execArgv: [`--user-agent=opencode/${Script.version}`, "--"],
|
||||
execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"],
|
||||
windows: {},
|
||||
},
|
||||
entrypoints: ["./src/index.ts", parserWorker, workerPath],
|
||||
|
||||
@@ -95,7 +95,6 @@ export namespace Agent {
|
||||
options: {},
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
hidden: true,
|
||||
},
|
||||
explore: {
|
||||
name: "explore",
|
||||
@@ -188,6 +187,7 @@ export namespace Agent {
|
||||
item.topP = value.top_p ?? item.topP
|
||||
item.mode = value.mode ?? item.mode
|
||||
item.color = value.color ?? item.color
|
||||
item.hidden = value.hidden ?? item.hidden
|
||||
item.name = value.name ?? item.name
|
||||
item.steps = value.steps ?? item.steps
|
||||
item.options = mergeDeep(item.options, value.options ?? {})
|
||||
|
||||
@@ -22,11 +22,10 @@ export const AttachCommand = cmd({
|
||||
}),
|
||||
handler: async (args) => {
|
||||
if (args.dir) process.chdir(args.dir)
|
||||
const directory = process.cwd()
|
||||
await tui({
|
||||
url: args.url,
|
||||
args: { sessionID: args.session },
|
||||
directory,
|
||||
directory: args.dir ? process.cwd() : undefined,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -75,9 +75,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
},
|
||||
color(name: string) {
|
||||
const agent = agents().find((x) => x.name === name)
|
||||
const all = sync.data.agent
|
||||
const agent = all.find((x) => x.name === name)
|
||||
if (agent?.color) return RGBA.fromHex(agent.color)
|
||||
const index = agents().findIndex((x) => x.name === name)
|
||||
const index = all.findIndex((x) => x.name === name)
|
||||
if (index === -1) return colors()[0]
|
||||
return colors()[index % colors().length]
|
||||
},
|
||||
|
||||
@@ -288,11 +288,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
|
||||
createEffect(() => {
|
||||
const theme = sync.data.config.theme
|
||||
console.log("theme", theme)
|
||||
if (theme) setStore("active", theme)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
function init() {
|
||||
resolveSystemTheme()
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
@@ -309,15 +309,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
setStore("ready", true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMount(init)
|
||||
|
||||
function resolveSystemTheme() {
|
||||
console.log("resolved system theme")
|
||||
console.log("resolveSystemTheme")
|
||||
renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
.then((colors) => {
|
||||
console.log(colors.palette)
|
||||
if (!colors.palette[0]) {
|
||||
if (store.active === "system") {
|
||||
setStore(
|
||||
@@ -341,11 +344,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const renderer = useRenderer()
|
||||
resolveSystemTheme()
|
||||
|
||||
const sdk = useSDK()
|
||||
sdk.event.on("server.instance.disposed", () => {
|
||||
resolveSystemTheme()
|
||||
process.on("SIGUSR2", async () => {
|
||||
renderer.clearPaletteCache()
|
||||
init()
|
||||
})
|
||||
|
||||
const values = createMemo(() => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
MacOSScrollAccel,
|
||||
type ScrollAcceleration,
|
||||
TextAttributes,
|
||||
RGBA,
|
||||
} from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
|
||||
@@ -1410,7 +1411,14 @@ function ToolTitle(props: { fallback: string; when: any; icon: string; children:
|
||||
)
|
||||
}
|
||||
|
||||
function InlineTool(props: { icon: string; complete: any; pending: string; children: JSX.Element; part: ToolPart }) {
|
||||
function InlineTool(props: {
|
||||
icon: string
|
||||
iconColor?: RGBA
|
||||
complete: any
|
||||
pending: string
|
||||
children: JSX.Element
|
||||
part: ToolPart
|
||||
}) {
|
||||
const [margin, setMargin] = createSignal(0)
|
||||
const { theme } = useTheme()
|
||||
const ctx = use()
|
||||
@@ -1461,7 +1469,7 @@ function InlineTool(props: { icon: string; complete: any; pending: string; child
|
||||
>
|
||||
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
|
||||
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
|
||||
<span style={{ bold: true }}>{props.icon}</span> {props.children}
|
||||
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
|
||||
</Show>
|
||||
</text>
|
||||
<Show when={error() && !denied()}>
|
||||
@@ -1644,8 +1652,10 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const { navigate } = useRoute()
|
||||
const local = useLocal()
|
||||
|
||||
const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending"))
|
||||
const color = createMemo(() => local.agent.color(props.input.subagent_type ?? "unknown"))
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
@@ -1679,11 +1689,13 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
<Match when={true}>
|
||||
<InlineTool
|
||||
icon="◉"
|
||||
iconColor={color()}
|
||||
pending="Delegating..."
|
||||
complete={props.input.subagent_type ?? props.input.description}
|
||||
part={props.part}
|
||||
>
|
||||
{Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}"
|
||||
<span style={{ fg: theme.text }}>{Locale.titlecase(props.input.subagent_type ?? "unknown")}</span> Task "
|
||||
{props.input.description}"
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -32,7 +32,7 @@ export namespace Clipboard {
|
||||
if (os === "win32" || release().includes("WSL")) {
|
||||
const script =
|
||||
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
||||
const base64 = await $`powershell.exe -command "${script}"`.nothrow().text()
|
||||
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
|
||||
if (base64) {
|
||||
const imageBuffer = Buffer.from(base64.trim(), "base64")
|
||||
if (imageBuffer.length > 0) {
|
||||
@@ -110,8 +110,9 @@ export namespace Clipboard {
|
||||
if (os === "win32") {
|
||||
console.log("clipboard: using powershell")
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/"/g, '""')
|
||||
await $`powershell -command "Set-Clipboard -Value \"${escaped}\""`.nothrow().quiet()
|
||||
// need to escape backticks because powershell uses them as escape code
|
||||
const escaped = text.replace(/"/g, '""').replace(/`/g, "``")
|
||||
await $`powershell -NonInteractive -NoProfile -Command "Set-Clipboard -Value \"${escaped}\""`.nothrow().quiet()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,9 +47,10 @@ export function FormatUnknownError(input: unknown): string {
|
||||
|
||||
if (typeof input === "object" && input !== null) {
|
||||
try {
|
||||
const json = JSON.stringify(input, null, 2)
|
||||
if (json && json !== "{}") return json
|
||||
} catch {}
|
||||
return JSON.stringify(input, null, 2)
|
||||
} catch {
|
||||
return "Unexpected error (unserializable)"
|
||||
}
|
||||
}
|
||||
|
||||
return String(input)
|
||||
|
||||
@@ -465,6 +465,10 @@ export namespace Config {
|
||||
disable: z.boolean().optional(),
|
||||
description: z.string().optional().describe("Description of when to use the agent"),
|
||||
mode: z.enum(["subagent", "primary", "all"]).optional(),
|
||||
hidden: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
|
||||
options: z.record(z.string(), z.any()).optional(),
|
||||
color: z
|
||||
.string()
|
||||
@@ -490,6 +494,7 @@ export namespace Config {
|
||||
"temperature",
|
||||
"top_p",
|
||||
"mode",
|
||||
"hidden",
|
||||
"color",
|
||||
"steps",
|
||||
"maxSteps",
|
||||
|
||||
@@ -232,6 +232,7 @@ export namespace PermissionNext {
|
||||
const result = new Set<string>()
|
||||
for (const tool of tools) {
|
||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||
|
||||
const rule = ruleset.findLast((r) => Wildcard.match(permission, r.permission))
|
||||
if (!rule) continue
|
||||
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
||||
|
||||
@@ -246,7 +246,12 @@ export namespace Server {
|
||||
},
|
||||
)
|
||||
.use(async (c, next) => {
|
||||
const directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
try {
|
||||
directory = decodeURIComponent(directory)
|
||||
} catch {
|
||||
// fallback to original value
|
||||
}
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
|
||||
@@ -37,7 +37,7 @@ import { SessionSummary } from "./summary"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { fn } from "@/util/fn"
|
||||
import { SessionProcessor } from "./processor"
|
||||
import { TaskTool } from "@/tool/task"
|
||||
import { TaskTool, filterSubagents, TASK_DESCRIPTION } from "@/tool/task"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { SessionStatus } from "./status"
|
||||
@@ -382,6 +382,8 @@ export namespace SessionPrompt {
|
||||
messageID: assistantMessage.id,
|
||||
sessionID: sessionID,
|
||||
abort,
|
||||
callID: part.callID,
|
||||
extra: { userInvokedAgents: [task.agent] },
|
||||
async metadata(input) {
|
||||
await Session.updatePart({
|
||||
...part,
|
||||
@@ -542,12 +544,20 @@ export namespace SessionPrompt {
|
||||
model,
|
||||
abort,
|
||||
})
|
||||
|
||||
// Track agents explicitly invoked by user via @ autocomplete
|
||||
const userInvokedAgents = msgs
|
||||
.filter((m) => m.info.role === "user")
|
||||
.flatMap((m) => m.parts.filter((p) => p.type === "agent") as MessageV2.AgentPart[])
|
||||
.map((p) => p.name)
|
||||
|
||||
const tools = await resolveTools({
|
||||
agent,
|
||||
session,
|
||||
model,
|
||||
tools: lastUser.tools,
|
||||
processor,
|
||||
userInvokedAgents,
|
||||
})
|
||||
|
||||
if (step === 1) {
|
||||
@@ -636,6 +646,7 @@ export namespace SessionPrompt {
|
||||
session: Session.Info
|
||||
tools?: Record<string, boolean>
|
||||
processor: SessionProcessor.Info
|
||||
userInvokedAgents: string[]
|
||||
}) {
|
||||
using _ = log.time("resolveTools")
|
||||
const tools: Record<string, AITool> = {}
|
||||
@@ -645,7 +656,7 @@ export namespace SessionPrompt {
|
||||
abort: options.abortSignal!,
|
||||
messageID: input.processor.message.id,
|
||||
callID: options.toolCallId,
|
||||
extra: { model: input.model },
|
||||
extra: { model: input.model, userInvokedAgents: input.userInvokedAgents },
|
||||
agent: input.agent.name,
|
||||
metadata: async (val: { title?: string; metadata?: any }) => {
|
||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
||||
@@ -788,6 +799,29 @@ export namespace SessionPrompt {
|
||||
}
|
||||
tools[key] = item
|
||||
}
|
||||
|
||||
// Regenerate task tool description with filtered subagents
|
||||
if (tools.task) {
|
||||
const all = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
|
||||
const filtered = filterSubagents(all, input.agent.permission)
|
||||
|
||||
// If no subagents are permitted, remove the task tool entirely
|
||||
if (filtered.length === 0) {
|
||||
delete tools.task
|
||||
} else {
|
||||
const description = TASK_DESCRIPTION.replace(
|
||||
"{agents}",
|
||||
filtered
|
||||
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
||||
.join("\n"),
|
||||
)
|
||||
tools.task = {
|
||||
...tools.task,
|
||||
description,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -1097,6 +1131,9 @@ export namespace SessionPrompt {
|
||||
}
|
||||
|
||||
if (part.type === "agent") {
|
||||
// Check if this agent would be denied by task permission
|
||||
const perm = PermissionNext.evaluate("task", part.name, agent.permission)
|
||||
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
@@ -1110,9 +1147,12 @@ export namespace SessionPrompt {
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
// An extra space is added here. Otherwise the 'Use' gets appended
|
||||
// to user's last word; making a combined word
|
||||
text:
|
||||
"Use the above message and context to generate a prompt and call the task tool with subagent: " +
|
||||
part.name,
|
||||
" Use the above message and context to generate a prompt and call the task tool with subagent: " +
|
||||
part.name +
|
||||
hint,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
60
packages/opencode/src/session/truncation.ts
Normal file
60
packages/opencode/src/session/truncation.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export namespace Truncate {
|
||||
export const MAX_LINES = 2000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
|
||||
export interface Result {
|
||||
content: string
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
maxLines?: number
|
||||
maxBytes?: number
|
||||
direction?: "head" | "tail"
|
||||
}
|
||||
|
||||
export function output(text: string, options: Options = {}): Result {
|
||||
const maxLines = options.maxLines ?? MAX_LINES
|
||||
const maxBytes = options.maxBytes ?? MAX_BYTES
|
||||
const direction = options.direction ?? "head"
|
||||
const lines = text.split("\n")
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
|
||||
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
||||
return { content: text, truncated: false }
|
||||
}
|
||||
|
||||
const out: string[] = []
|
||||
var i = 0
|
||||
var bytes = 0
|
||||
var hitBytes = false
|
||||
|
||||
if (direction === "head") {
|
||||
for (i = 0; i < lines.length && i < maxLines; i++) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.push(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
||||
const unit = hitBytes ? "chars" : "lines"
|
||||
return { content: `${out.join("\n")}\n\n...${removed} ${unit} truncated...`, truncated: true }
|
||||
}
|
||||
|
||||
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.unshift(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
||||
const unit = hitBytes ? "chars" : "lines"
|
||||
return { content: `...${removed} ${unit} truncated...\n\n${out.join("\n")}`, truncated: true }
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { CodeSearchTool } from "./codesearch"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "@/util/log"
|
||||
import { LspTool } from "./lsp"
|
||||
import { Truncate } from "../session/truncation"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
@@ -64,10 +65,11 @@ export namespace ToolRegistry {
|
||||
description: def.description,
|
||||
execute: async (args, ctx) => {
|
||||
const result = await def.execute(args as any, ctx)
|
||||
const out = Truncate.output(result)
|
||||
return {
|
||||
title: "",
|
||||
output: result,
|
||||
metadata: {},
|
||||
output: out.truncated ? out.content : result,
|
||||
metadata: { truncated: out.truncated },
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -10,6 +10,13 @@ import { SessionPrompt } from "../session/prompt"
|
||||
import { iife } from "@/util/iife"
|
||||
import { defer } from "@/util/defer"
|
||||
import { Config } from "../config/config"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
|
||||
export { DESCRIPTION as TASK_DESCRIPTION }
|
||||
|
||||
export function filterSubagents(agents: Agent.Info[], ruleset: PermissionNext.Ruleset) {
|
||||
return agents.filter((a) => PermissionNext.evaluate("task", a.name, ruleset).action !== "deny")
|
||||
}
|
||||
|
||||
export const TaskTool = Tool.define("task", async () => {
|
||||
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
|
||||
@@ -30,15 +37,20 @@ export const TaskTool = Tool.define("task", async () => {
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const config = await Config.get()
|
||||
await ctx.ask({
|
||||
permission: "task",
|
||||
patterns: [params.subagent_type],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
description: params.description,
|
||||
subagent_type: params.subagent_type,
|
||||
},
|
||||
})
|
||||
|
||||
const userInvokedAgents = (ctx.extra?.userInvokedAgents ?? []) as string[]
|
||||
// Skip permission check when invoked from a command subtask (user already approved by invoking the command)
|
||||
if (!ctx.extra?.bypassAgentCheck && !userInvokedAgents.includes(params.subagent_type)) {
|
||||
await ctx.ask({
|
||||
permission: "task",
|
||||
patterns: [params.subagent_type],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
description: params.description,
|
||||
subagent_type: params.subagent_type,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const agent = await Agent.get(params.subagent_type)
|
||||
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
|
||||
|
||||
@@ -2,6 +2,7 @@ import z from "zod"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import type { PermissionNext } from "../permission/next"
|
||||
import { Truncate } from "../session/truncation"
|
||||
|
||||
export namespace Tool {
|
||||
interface Metadata {
|
||||
@@ -52,7 +53,7 @@ export namespace Tool {
|
||||
init: async (ctx) => {
|
||||
const toolInfo = init instanceof Function ? await init(ctx) : init
|
||||
const execute = toolInfo.execute
|
||||
toolInfo.execute = (args, ctx) => {
|
||||
toolInfo.execute = async (args, ctx) => {
|
||||
try {
|
||||
toolInfo.parameters.parse(args)
|
||||
} catch (error) {
|
||||
@@ -64,7 +65,16 @@ export namespace Tool {
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
return execute(args, ctx)
|
||||
const result = await execute(args, ctx)
|
||||
const truncated = Truncate.output(result.output)
|
||||
return {
|
||||
...result,
|
||||
output: truncated.content,
|
||||
metadata: {
|
||||
...result.metadata,
|
||||
truncated: truncated.truncated,
|
||||
},
|
||||
}
|
||||
}
|
||||
return toolInfo
|
||||
},
|
||||
|
||||
@@ -82,7 +82,7 @@ test("general agent denies todo tools", async () => {
|
||||
const general = await Agent.get("general")
|
||||
expect(general).toBeDefined()
|
||||
expect(general?.mode).toBe("subagent")
|
||||
expect(general?.hidden).toBe(true)
|
||||
expect(general?.hidden).toBeUndefined()
|
||||
expect(evalPerm(general, "todoread")).toBe("deny")
|
||||
expect(evalPerm(general, "todowrite")).toBe("deny")
|
||||
},
|
||||
|
||||
459
packages/opencode/test/permission-task.test.ts
Normal file
459
packages/opencode/test/permission-task.test.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import type { Agent } from "../src/agent/agent"
|
||||
import { filterSubagents } from "../src/tool/task"
|
||||
import { PermissionNext } from "../src/permission/next"
|
||||
import { Config } from "../src/config/config"
|
||||
import { Instance } from "../src/project/instance"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
describe("filterSubagents - permission.task filtering", () => {
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
|
||||
Object.entries(rules).map(([pattern, action]) => ({
|
||||
permission: "task",
|
||||
pattern,
|
||||
action,
|
||||
}))
|
||||
|
||||
const mockAgents = [
|
||||
{ name: "general", mode: "subagent", permission: [], options: {} },
|
||||
{ name: "code-reviewer", mode: "subagent", permission: [], options: {} },
|
||||
{ name: "orchestrator-fast", mode: "subagent", permission: [], options: {} },
|
||||
{ name: "orchestrator-slow", mode: "subagent", permission: [], options: {} },
|
||||
] as Agent.Info[]
|
||||
|
||||
test("returns all agents when permissions config is empty", () => {
|
||||
const result = filterSubagents(mockAgents, [])
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("excludes agents with explicit deny", () => {
|
||||
const ruleset = createRuleset({ "code-reviewer": "deny" })
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("includes agents with explicit allow", () => {
|
||||
const ruleset = createRuleset({
|
||||
"code-reviewer": "allow",
|
||||
general: "deny",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("includes agents with ask permission (user approval is runtime behavior)", () => {
|
||||
const ruleset = createRuleset({
|
||||
"code-reviewer": "ask",
|
||||
general: "deny",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("includes agents with undefined permission (default allow)", () => {
|
||||
const ruleset = createRuleset({
|
||||
general: "deny",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("supports wildcard patterns with deny", () => {
|
||||
const ruleset = createRuleset({ "orchestrator-*": "deny" })
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
|
||||
})
|
||||
|
||||
test("supports wildcard patterns with allow", () => {
|
||||
const ruleset = createRuleset({
|
||||
"*": "allow",
|
||||
"orchestrator-fast": "deny",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("supports wildcard patterns with ask", () => {
|
||||
const ruleset = createRuleset({
|
||||
"orchestrator-*": "ask",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("longer pattern takes precedence over shorter pattern", () => {
|
||||
const ruleset = createRuleset({
|
||||
"orchestrator-*": "deny",
|
||||
"orchestrator-fast": "allow",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
|
||||
})
|
||||
|
||||
test("edge case: all agents denied", () => {
|
||||
const ruleset = createRuleset({ "*": "deny" })
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(0)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("edge case: mixed patterns with multiple wildcards", () => {
|
||||
const ruleset = createRuleset({
|
||||
"*": "ask",
|
||||
"orchestrator-*": "deny",
|
||||
"orchestrator-fast": "allow",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
|
||||
})
|
||||
|
||||
test("hidden: true does not affect filtering (hidden only affects autocomplete)", () => {
|
||||
const agents = [
|
||||
{ name: "general", mode: "subagent", hidden: true, permission: [], options: {} },
|
||||
{ name: "code-reviewer", mode: "subagent", hidden: false, permission: [], options: {} },
|
||||
{ name: "orchestrator", mode: "subagent", permission: [], options: {} },
|
||||
] as Agent.Info[]
|
||||
|
||||
const result = filterSubagents(agents, [])
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator"])
|
||||
})
|
||||
|
||||
test("hidden: true agents can be filtered by permission.task deny", () => {
|
||||
const agents = [
|
||||
{ name: "general", mode: "subagent", hidden: true, permission: [], options: {} },
|
||||
{ name: "orchestrator-coder", mode: "subagent", hidden: true, permission: [], options: {} },
|
||||
] as Agent.Info[]
|
||||
|
||||
const ruleset = createRuleset({ general: "deny" })
|
||||
const result = filterSubagents(agents, ruleset)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result.map((a) => a.name)).toEqual(["orchestrator-coder"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("PermissionNext.evaluate for permission.task", () => {
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
|
||||
Object.entries(rules).map(([pattern, action]) => ({
|
||||
permission: "task",
|
||||
pattern,
|
||||
action,
|
||||
}))
|
||||
|
||||
test("returns ask when no match (default)", () => {
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("returns deny for explicit deny", () => {
|
||||
const ruleset = createRuleset({ "code-reviewer": "deny" })
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
})
|
||||
|
||||
test("returns allow for explicit allow", () => {
|
||||
const ruleset = createRuleset({ "code-reviewer": "allow" })
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
|
||||
})
|
||||
|
||||
test("returns ask for explicit ask", () => {
|
||||
const ruleset = createRuleset({ "code-reviewer": "ask" })
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("matches wildcard patterns with deny", () => {
|
||||
const ruleset = createRuleset({ "orchestrator-*": "deny" })
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("matches wildcard patterns with allow", () => {
|
||||
const ruleset = createRuleset({ "orchestrator-*": "allow" })
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
|
||||
})
|
||||
|
||||
test("matches wildcard patterns with ask", () => {
|
||||
const ruleset = createRuleset({ "orchestrator-*": "ask" })
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
|
||||
const globalRuleset = createRuleset({ "*": "ask" })
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
|
||||
})
|
||||
|
||||
test("later rules take precedence (last match wins)", () => {
|
||||
const ruleset = createRuleset({
|
||||
"orchestrator-*": "deny",
|
||||
"orchestrator-fast": "allow",
|
||||
})
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
|
||||
})
|
||||
|
||||
test("matches global wildcard", () => {
|
||||
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
|
||||
})
|
||||
})
|
||||
|
||||
describe("PermissionNext.disabled for task tool", () => {
|
||||
// Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
|
||||
// It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
|
||||
// It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
|
||||
Object.entries(rules).map(([pattern, action]) => ({
|
||||
permission: "task",
|
||||
pattern,
|
||||
action,
|
||||
}))
|
||||
|
||||
test("task tool is disabled when global deny pattern exists (even with specific allows)", () => {
|
||||
// When "*": "deny" exists, the task tool is disabled because the disabled() function
|
||||
// only checks for wildcard deny patterns - it doesn't consider that specific subagents might be allowed
|
||||
const ruleset = createRuleset({
|
||||
"orchestrator-*": "allow",
|
||||
"*": "deny",
|
||||
})
|
||||
const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset)
|
||||
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
})
|
||||
|
||||
test("task tool is disabled when global deny pattern exists (even with ask overrides)", () => {
|
||||
const ruleset = createRuleset({
|
||||
"orchestrator-*": "ask",
|
||||
"*": "deny",
|
||||
})
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
})
|
||||
|
||||
test("task tool is disabled when global deny pattern exists", () => {
|
||||
const ruleset = createRuleset({ "*": "deny" })
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
})
|
||||
|
||||
test("task tool is NOT disabled when only specific patterns are denied (no wildcard)", () => {
|
||||
// The disabled() function only disables tools when pattern: "*" && action: "deny"
|
||||
// Specific subagent denies don't disable the task tool - those are handled at runtime
|
||||
const ruleset = createRuleset({
|
||||
"orchestrator-*": "deny",
|
||||
general: "deny",
|
||||
})
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
// The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
})
|
||||
|
||||
test("task tool is enabled when no task rules exist (default ask)", () => {
|
||||
const disabled = PermissionNext.disabled(["task"], [])
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
})
|
||||
|
||||
test("task tool is NOT disabled when last wildcard pattern is allow", () => {
|
||||
// Last matching rule wins - if wildcard allow comes after wildcard deny, tool is enabled
|
||||
const ruleset = createRuleset({
|
||||
"*": "deny",
|
||||
"orchestrator-coder": "allow",
|
||||
})
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
// The disabled() function uses findLast and checks if the last matching rule
|
||||
// has pattern: "*" and action: "deny". In this case, the last rule matching
|
||||
// "task" permission has pattern "orchestrator-coder", not "*", so not disabled
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Integration tests that load permissions from real config files
|
||||
describe("permission.task with real config files", () => {
|
||||
const mockAgents = [
|
||||
{ name: "general", mode: "subagent", permission: [], options: {} },
|
||||
{ name: "code-reviewer", mode: "subagent", permission: [], options: {} },
|
||||
{ name: "orchestrator-fast", mode: "subagent", permission: [], options: {} },
|
||||
] as Agent.Info[]
|
||||
|
||||
test("loads task permissions from opencode.json config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
config: {
|
||||
permission: {
|
||||
task: {
|
||||
"*": "allow",
|
||||
"code-reviewer": "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast"])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads task permissions with wildcard patterns from config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
config: {
|
||||
permission: {
|
||||
task: {
|
||||
"*": "ask",
|
||||
"orchestrator-*": "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("evaluate respects task permission from config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
config: {
|
||||
permission: {
|
||||
task: {
|
||||
general: "allow",
|
||||
"code-reviewer": "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
// Unspecified agents default to "ask"
|
||||
expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("mixed permission config with task and other tools", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
config: {
|
||||
permission: {
|
||||
bash: "allow",
|
||||
edit: "ask",
|
||||
task: {
|
||||
"*": "deny",
|
||||
general: "allow",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
|
||||
// Verify task permissions
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
|
||||
// Verify other tool permissions
|
||||
expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask")
|
||||
|
||||
// Verify disabled tools
|
||||
const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset)
|
||||
expect(disabled.has("bash")).toBe(false)
|
||||
expect(disabled.has("edit")).toBe(false)
|
||||
// task is NOT disabled because disabled() uses findLast, and the last rule
|
||||
// matching "task" permission is {pattern: "general", action: "allow"}, not pattern: "*"
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("task tool disabled when global deny comes last in config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
config: {
|
||||
permission: {
|
||||
task: {
|
||||
general: "allow",
|
||||
"code-reviewer": "allow",
|
||||
"*": "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
|
||||
// Last matching rule wins - "*" deny is last, so all agents are denied
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny")
|
||||
|
||||
// Since "*": "deny" is the last rule, disabled() finds it with findLast
|
||||
// and sees pattern: "*" with action: "deny", so task is disabled
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
expect(disabled.has("task")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("task tool NOT disabled when specific allow comes last in config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
config: {
|
||||
permission: {
|
||||
task: {
|
||||
"*": "deny",
|
||||
general: "allow",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
|
||||
// Evaluate uses findLast - "general" allow comes after "*" deny
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
// Other agents still denied by the earlier "*" deny
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
|
||||
// disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
|
||||
// In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
|
||||
// So the task tool is NOT disabled (even though most subagents are denied)
|
||||
const disabled = PermissionNext.disabled(["task"], ruleset)
|
||||
expect(disabled.has("task")).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
33453
packages/opencode/test/session/fixtures/models-api.json
Normal file
33453
packages/opencode/test/session/fixtures/models-api.json
Normal file
File diff suppressed because it is too large
Load Diff
79
packages/opencode/test/session/truncation.test.ts
Normal file
79
packages/opencode/test/session/truncation.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { Truncate } from "../../src/session/truncation"
|
||||
import path from "path"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
|
||||
describe("Truncate", () => {
|
||||
describe("output", () => {
|
||||
test("truncates large json file by bytes", async () => {
|
||||
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
|
||||
const result = Truncate.output(content)
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(Buffer.byteLength(result.content, "utf-8")).toBeLessThanOrEqual(Truncate.MAX_BYTES + 100)
|
||||
expect(result.content).toContain("truncated...")
|
||||
})
|
||||
|
||||
test("returns content unchanged when under limits", () => {
|
||||
const content = "line1\nline2\nline3"
|
||||
const result = Truncate.output(content)
|
||||
|
||||
expect(result.truncated).toBe(false)
|
||||
expect(result.content).toBe(content)
|
||||
})
|
||||
|
||||
test("truncates by line count", () => {
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||
const result = Truncate.output(lines, { maxLines: 10 })
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.content.split("\n").length).toBeLessThanOrEqual(12)
|
||||
expect(result.content).toContain("...90 lines truncated...")
|
||||
})
|
||||
|
||||
test("truncates by byte count", () => {
|
||||
const content = "a".repeat(1000)
|
||||
const result = Truncate.output(content, { maxBytes: 100 })
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.content).toContain("truncated...")
|
||||
})
|
||||
|
||||
test("truncates from head by default", () => {
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
||||
const result = Truncate.output(lines, { maxLines: 3 })
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.content).toContain("line0")
|
||||
expect(result.content).toContain("line1")
|
||||
expect(result.content).toContain("line2")
|
||||
expect(result.content).not.toContain("line9")
|
||||
})
|
||||
|
||||
test("truncates from tail when direction is tail", () => {
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
||||
const result = Truncate.output(lines, { maxLines: 3, direction: "tail" })
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.content).toContain("line7")
|
||||
expect(result.content).toContain("line8")
|
||||
expect(result.content).toContain("line9")
|
||||
expect(result.content).not.toContain("line0")
|
||||
})
|
||||
|
||||
test("uses default MAX_LINES and MAX_BYTES", () => {
|
||||
expect(Truncate.MAX_LINES).toBe(2000)
|
||||
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
|
||||
})
|
||||
|
||||
test("large single-line file truncates with byte message", async () => {
|
||||
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
|
||||
const result = Truncate.output(content)
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.content).toContain("chars truncated...")
|
||||
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -19,9 +19,11 @@ export function createOpencodeClient(config?: Config & { directory?: string }) {
|
||||
}
|
||||
|
||||
if (config?.directory) {
|
||||
const isNonASCII = /[^\x00-\x7F]/.test(config.directory)
|
||||
const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
"x-opencode-directory": config.directory,
|
||||
"x-opencode-directory": encodedDirectory,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1259,6 +1259,10 @@ export type AgentConfig = {
|
||||
*/
|
||||
description?: string
|
||||
mode?: "subagent" | "primary" | "all"
|
||||
/**
|
||||
* Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)
|
||||
*/
|
||||
hidden?: boolean
|
||||
options?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -8350,6 +8350,10 @@
|
||||
"type": "string",
|
||||
"enum": ["subagent", "primary", "all"]
|
||||
},
|
||||
"hidden": {
|
||||
"description": "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
@@ -44,6 +44,7 @@
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { FileDiff } from "@pierre/diffs"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createEffect, createMemo, onCleanup, splitProps } from "solid-js"
|
||||
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
|
||||
import { getWorkerPool } from "../pierre/worker"
|
||||
@@ -8,10 +9,19 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"])
|
||||
|
||||
const options = createMemo(() => ({
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
}))
|
||||
const mobile = createMediaQuery("(max-width: 640px)")
|
||||
|
||||
const options = createMemo(() => {
|
||||
const opts = {
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
}
|
||||
if (!mobile()) return opts
|
||||
return {
|
||||
...opts,
|
||||
disableLineNumbers: true,
|
||||
}
|
||||
})
|
||||
|
||||
let instance: FileDiff<T> | undefined
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { entries, flatMap, groupBy, map, pipe } from "remeda"
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createList } from "solid-list"
|
||||
|
||||
@@ -86,9 +86,14 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(grouped, () => {
|
||||
reset()
|
||||
}),
|
||||
)
|
||||
|
||||
const onInput = (value: string) => {
|
||||
setStore("filter", value)
|
||||
reset()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -372,3 +372,17 @@ input:where([type="button"], [type="reset"], [type="submit"]),
|
||||
[hidden]:where(:not([hidden="until-found"])) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent iOS Safari from auto-zooming on input focus.
|
||||
iOS WebKit zooms on any input with font-size < 16px as an accessibility feature.
|
||||
*/
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
[contenteditable="true"] {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import catppuccinThemeJson from "./themes/catppuccin.json"
|
||||
import ayuThemeJson from "./themes/ayu.json"
|
||||
import oneDarkProThemeJson from "./themes/onedarkpro.json"
|
||||
import shadesOfPurpleThemeJson from "./themes/shadesofpurple.json"
|
||||
import nightowlThemeJson from "./themes/nightowl.json"
|
||||
|
||||
export const oc1Theme = oc1ThemeJson as DesktopTheme
|
||||
export const tokyonightTheme = tokyoThemeJson as DesktopTheme
|
||||
@@ -20,6 +21,7 @@ export const catppuccinTheme = catppuccinThemeJson as DesktopTheme
|
||||
export const ayuTheme = ayuThemeJson as DesktopTheme
|
||||
export const oneDarkProTheme = oneDarkProThemeJson as DesktopTheme
|
||||
export const shadesOfPurpleTheme = shadesOfPurpleThemeJson as DesktopTheme
|
||||
export const nightowlTheme = nightowlThemeJson as DesktopTheme
|
||||
|
||||
export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
|
||||
"oc-1": oc1Theme,
|
||||
@@ -32,4 +34,5 @@ export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
|
||||
ayu: ayuTheme,
|
||||
onedarkpro: oneDarkProTheme,
|
||||
shadesofpurple: shadesOfPurpleTheme,
|
||||
nightowl: nightowlTheme,
|
||||
}
|
||||
|
||||
@@ -41,4 +41,5 @@ export {
|
||||
ayuTheme,
|
||||
oneDarkProTheme,
|
||||
shadesOfPurpleTheme,
|
||||
nightowlTheme,
|
||||
} from "./default-themes"
|
||||
|
||||
131
packages/ui/src/theme/themes/nightowl.json
Normal file
131
packages/ui/src/theme/themes/nightowl.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/desktop-theme.json",
|
||||
"name": "Night Owl",
|
||||
"id": "nightowl",
|
||||
"light": {
|
||||
"seeds": {
|
||||
"neutral": "#f0f0f0",
|
||||
"primary": "#4876d6",
|
||||
"success": "#2aa298",
|
||||
"warning": "#c96765",
|
||||
"error": "#de3d3b",
|
||||
"info": "#4876d6",
|
||||
"interactive": "#4876d6",
|
||||
"diffAdd": "#2aa298",
|
||||
"diffDelete": "#de3d3b"
|
||||
},
|
||||
"overrides": {
|
||||
"background-base": "#fbfbfb",
|
||||
"background-weak": "#f0f0f0",
|
||||
"background-strong": "#ffffff",
|
||||
"background-stronger": "#ffffff",
|
||||
"border-weak-base": "#d9d9d9",
|
||||
"border-weak-hover": "#cccccc",
|
||||
"border-weak-active": "#bfbfbf",
|
||||
"border-weak-selected": "#4876d6",
|
||||
"border-weak-disabled": "#e6e6e6",
|
||||
"border-weak-focus": "#4876d6",
|
||||
"border-base": "#c0c0c0",
|
||||
"border-hover": "#b3b3b3",
|
||||
"border-active": "#a6a6a6",
|
||||
"border-selected": "#4876d6",
|
||||
"border-disabled": "#d9d9d9",
|
||||
"border-focus": "#4876d6",
|
||||
"border-strong-base": "#90a7b2",
|
||||
"border-strong-hover": "#7d9aa6",
|
||||
"border-strong-active": "#6a8d9a",
|
||||
"border-strong-selected": "#4876d6",
|
||||
"border-strong-disabled": "#c0c0c0",
|
||||
"border-strong-focus": "#4876d6",
|
||||
"surface-diff-add-base": "#eaf8f6",
|
||||
"surface-diff-delete-base": "#fbe9e9",
|
||||
"surface-diff-hidden-base": "#e8f0fc",
|
||||
"text-base": "#403f53",
|
||||
"text-weak": "#7a8181",
|
||||
"text-strong": "#1a1a1a",
|
||||
"syntax-string": "#c96765",
|
||||
"syntax-primitive": "#aa0982",
|
||||
"syntax-property": "#4876d6",
|
||||
"syntax-type": "#994cc3",
|
||||
"syntax-constant": "#2aa298",
|
||||
"syntax-info": "#4876d6",
|
||||
"markdown-heading": "#4876d6",
|
||||
"markdown-text": "#403f53",
|
||||
"markdown-link": "#4876d6",
|
||||
"markdown-link-text": "#2aa298",
|
||||
"markdown-code": "#2aa298",
|
||||
"markdown-block-quote": "#7a8181",
|
||||
"markdown-emph": "#994cc3",
|
||||
"markdown-strong": "#c96765",
|
||||
"markdown-horizontal-rule": "#90a7b2",
|
||||
"markdown-list-item": "#4876d6",
|
||||
"markdown-list-enumeration": "#2aa298",
|
||||
"markdown-image": "#4876d6",
|
||||
"markdown-image-text": "#2aa298",
|
||||
"markdown-code-block": "#403f53"
|
||||
}
|
||||
},
|
||||
"dark": {
|
||||
"seeds": {
|
||||
"neutral": "#011627",
|
||||
"primary": "#82aaff",
|
||||
"success": "#c5e478",
|
||||
"warning": "#ecc48d",
|
||||
"error": "#ef5350",
|
||||
"info": "#82aaff",
|
||||
"interactive": "#82aaff",
|
||||
"diffAdd": "#c5e478",
|
||||
"diffDelete": "#ef5350"
|
||||
},
|
||||
"overrides": {
|
||||
"background-base": "#011627",
|
||||
"background-weak": "#0b253a",
|
||||
"background-strong": "#001122",
|
||||
"background-stronger": "#000c17",
|
||||
"border-weak-base": "#1d3b53",
|
||||
"border-weak-hover": "#234561",
|
||||
"border-weak-active": "#2a506f",
|
||||
"border-weak-selected": "#82aaff",
|
||||
"border-weak-disabled": "#0f2132",
|
||||
"border-weak-focus": "#82aaff",
|
||||
"border-base": "#3a5a75",
|
||||
"border-hover": "#456785",
|
||||
"border-active": "#507494",
|
||||
"border-selected": "#82aaff",
|
||||
"border-disabled": "#1a3347",
|
||||
"border-focus": "#82aaff",
|
||||
"border-strong-base": "#5f7e97",
|
||||
"border-strong-hover": "#6e8da6",
|
||||
"border-strong-active": "#7d9cb5",
|
||||
"border-strong-selected": "#82aaff",
|
||||
"border-strong-disabled": "#2c4a63",
|
||||
"border-strong-focus": "#82aaff",
|
||||
"surface-diff-add-base": "#0a2e1a",
|
||||
"surface-diff-delete-base": "#2d1b1b",
|
||||
"surface-diff-hidden-base": "#0b253a",
|
||||
"text-base": "#d6deeb",
|
||||
"text-weak": "#5f7e97",
|
||||
"text-strong": "#ffffff",
|
||||
"syntax-string": "#ecc48d",
|
||||
"syntax-primitive": "#f78c6c",
|
||||
"syntax-property": "#82aaff",
|
||||
"syntax-type": "#c5e478",
|
||||
"syntax-constant": "#7fdbca",
|
||||
"syntax-info": "#82aaff",
|
||||
"markdown-heading": "#82aaff",
|
||||
"markdown-text": "#d6deeb",
|
||||
"markdown-link": "#82aaff",
|
||||
"markdown-link-text": "#7fdbca",
|
||||
"markdown-code": "#c5e478",
|
||||
"markdown-block-quote": "#5f7e97",
|
||||
"markdown-emph": "#c792ea",
|
||||
"markdown-strong": "#ecc48d",
|
||||
"markdown-horizontal-rule": "#5f7e97",
|
||||
"markdown-list-item": "#82aaff",
|
||||
"markdown-list-enumeration": "#7fdbca",
|
||||
"markdown-image": "#82aaff",
|
||||
"markdown-image-text": "#7fdbca",
|
||||
"markdown-code-block": "#d6deeb"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.6",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user