Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b2341c2d9a | ||
|
|
dadddcaf57 |
@@ -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
-3
@@ -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
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"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.3",
|
||||
"version": "1.1.5",
|
||||
"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.3",
|
||||
"version": "1.1.5",
|
||||
"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.3",
|
||||
"version": "1.1.5",
|
||||
"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.3",
|
||||
"version": "1.1.5",
|
||||
"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.3",
|
||||
"version": "1.1.5",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -230,7 +231,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -246,7 +247,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -349,7 +350,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -369,7 +370,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -380,7 +381,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -393,7 +394,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"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.3",
|
||||
"version": "1.1.5",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -442,7 +444,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"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
-1
@@ -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.3",
|
||||
"version": "1.1.5",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
+73
-67
@@ -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[]
|
||||
}>({
|
||||
@@ -1593,14 +1593,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> {
|
||||
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 [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
|
||||
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.3",
|
||||
"version": "1.1.5",
|
||||
"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
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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`);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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.3",
|
||||
"version": "1.1.5",
|
||||
"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": {
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
Executable
+28
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Vendored
+4
@@ -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.3",
|
||||
"version": "1.1.5",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+4
@@ -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.3",
|
||||
"version": "1.1.5",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
+4
@@ -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.3",
|
||||
"version": "1.1.5",
|
||||
"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",
|
||||
|
||||
Generated
+18
@@ -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(())
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"headerImage": "assets/nsis-header.bmp",
|
||||
"sidebarImage": "assets/nsis-sidebar.bmp"
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+149
-48
@@ -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.3",
|
||||
"version": "1.1.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
Vendored
+4
@@ -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.3"
|
||||
version = "1.1.5"
|
||||
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.3/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.5/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.3/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.5/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.5/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.3/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.5/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.3/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.5/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Vendored
+4
@@ -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.3",
|
||||
"version": "1.1.5",
|
||||
"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,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -89,9 +89,7 @@ export function Prompt(props: PromptProps) {
|
||||
const fileStyleId = syntax().getStyleId("extmark.file")!
|
||||
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
||||
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
||||
const commandStyleId = syntax().getStyleId("extmark.command")!
|
||||
let promptPartTypeId: number
|
||||
let commandHighlightRef = 0
|
||||
|
||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||
input.insertText(evt.properties.text)
|
||||
@@ -399,25 +397,6 @@ export function Prompt(props: PromptProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function highlightSlashCommands() {
|
||||
// Remove previous command highlights
|
||||
input.removeHighlightsByRef(commandHighlightRef)
|
||||
commandHighlightRef++
|
||||
|
||||
const text = input.plainText
|
||||
// Match all slash commands (e.g. /help, /status, /compact)
|
||||
const regex = /\/[a-zA-Z_][a-zA-Z0-9_-]*/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
input.addHighlightByCharRange({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
styleId: commandStyleId,
|
||||
hlRef: commandHighlightRef,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
title: "Stash prompt",
|
||||
@@ -793,7 +772,6 @@ export function Prompt(props: PromptProps) {
|
||||
setStore("prompt", "input", value)
|
||||
autocomplete.onInput(value)
|
||||
syncExtmarksWithPromptParts()
|
||||
highlightSlashCommands()
|
||||
}}
|
||||
keyBindings={textareaKeybindings()}
|
||||
onKeyDown={async (e) => {
|
||||
|
||||
@@ -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]
|
||||
},
|
||||
|
||||
@@ -312,7 +312,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
function resolveSystemTheme() {
|
||||
console.log("resolved system theme")
|
||||
console.log("resolveSystemTheme")
|
||||
renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
@@ -343,8 +343,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
const renderer = useRenderer()
|
||||
resolveSystemTheme()
|
||||
|
||||
const sdk = useSDK()
|
||||
sdk.event.on("server.instance.disposed", () => {
|
||||
process.on("SIGUSR2", () => {
|
||||
renderer.clearPaletteCache()
|
||||
resolveSystemTheme()
|
||||
})
|
||||
|
||||
@@ -679,13 +679,6 @@ function getSyntaxRules(theme: Theme) {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["extmark.command"],
|
||||
style: {
|
||||
foreground: theme.accent,
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["comment"],
|
||||
style: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.3",
|
||||
"version": "1.1.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -25,4 +25,4 @@
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -30,4 +30,4 @@
|
||||
"publishConfig": {
|
||||
"directory": "dist"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.3",
|
||||
"version": "1.1.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"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
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
[data-slot="user-message-text"] {
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
background: var(--surface-base);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
|
||||
[data-slot="session-turn-message-content"] {
|
||||
margin-top: -18px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-message-title"] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user