Compare commits

...

44 Commits

Author SHA1 Message Date
Aiden Cline
a0fd15e9e4 rm dead code 2026-01-09 17:41:36 -06:00
Aiden Cline
4f2514d9f5 rm comment 2026-01-09 17:40:32 -06:00
Aiden Cline
5d9801f206 rm unnecessary code 2026-01-09 17:39:30 -06:00
Aiden Cline
a300cd6465 fix: model list 2026-01-09 17:06:13 -06:00
Aiden Cline
399699469b workin 2026-01-09 16:43:11 -06:00
Aiden Cline
cacf9df24c filter out old plugin 2026-01-09 16:35:00 -06:00
Aiden Cline
2844086752 wip: codex 2026-01-09 16:27:44 -06:00
Ariane Emory
75df5040ea fix: report config errors instead of silent termination (#7522) 2026-01-09 14:21:01 -06:00
Github Action
f2b2940298 Update Nix flake.lock and hashes 2026-01-09 19:20:11 +00:00
Sebastian Herrlinger
22f51c6b47 upgrade opentui to v0.1.72 2026-01-09 20:18:34 +01:00
Aiden Cline
de286b08f6 ignore: bump plugin version 2026-01-09 12:33:51 -06:00
adamelmore
0f2124db32 fix(app): no inline js 2026-01-09 11:57:31 -06:00
Ravi Kumar
e30a159264 fix(cli): enable API key prompt for Bedrock in auth login (#7332) 2026-01-09 11:56:19 -06:00
ryanwyler
a5edf3a311 fix: resolve broken forked sessions with compactions due to missing parent-child message references (#6445) 2026-01-09 11:42:55 -06:00
Aiden Cline
7c2907cbb4 ci: fix failing pipeline 2026-01-09 11:38:01 -06:00
GitHub Action
bce9dc040f chore: generate 2026-01-09 17:03:51 +00:00
adamelmore
44297ffe78 fix(app): break words in user message 2026-01-09 11:03:11 -06:00
adamelmore
cbb3141130 fix(app): no custom url param 2026-01-09 11:03:11 -06:00
Frank
18cf4df6c6 wip: zen 2026-01-09 11:52:31 -05:00
Aiden Cline
f3e8a275b8 docs: update brew formula references 2026-01-09 10:39:59 -06:00
Github Action
7df36cf0fa Update Nix flake.lock and hashes 2026-01-09 16:28:31 +00:00
Sebastian Herrlinger
e82b112759 upgrade opentui to v0.1.71, fixing capability detection and therefor kitty keyboard 2026-01-09 17:26:33 +01:00
AJ Ka
030b14ac4e fix: prevent model picker overflow with Zen in new sessions (#7495) 2026-01-09 09:53:18 -06:00
Jérôme Benoit
b1e381cff7 fix(desktop): complete symlink support implementation and enable CI for PRs (#7113) 2026-01-09 22:43:49 +08:00
adamelmore
0433d4d064 fix(app): store terminal and review pane visibility per session 2026-01-09 08:20:00 -06:00
GitHub Action
d34fdac854 ignore: update download stats 2026-01-09 2026-01-09 12:04:56 +00:00
adamelmore
ec828619ca fix(app): more defensive todo access 2026-01-09 06:02:45 -06:00
adamelmore
559013e124 docs: perf plans 2026-01-09 05:07:16 -06:00
Jeon Suyeol
8e3ab4afa7 feat(config): deduplicate plugins by name with priority-based resolution (#5957) 2026-01-09 02:11:24 -06:00
Aiden Cline
13305966e5 ci: tweak pr standards workflow 2026-01-09 00:54:42 -06:00
Aiden Cline
a618fbe8cf bump copilot plugin version 2026-01-09 01:32:00 -05:00
Aiden Cline
07dc1f8ecc fix: model dialog issue when searching for models in fav/recents list, also ensure that deprecated models dont appear in list (#7429) 2026-01-09 01:32:00 -05:00
GitHub Action
445c8631a2 chore: generate 2026-01-09 01:32:00 -05:00
Frank
c4eacd0ccf wip: black 2026-01-09 01:32:00 -05:00
Frank
dd5ec26c8c wip: black 2026-01-09 01:32:00 -05:00
Daniel Polito
8b062ed621 feat(desktop): Change Terminal's Cursor to Bar and Stop Blinking when not focused (#7402) 2026-01-09 01:32:00 -05:00
Frank
ab97a95032 wip: black 2026-01-09 01:32:00 -05:00
GitHub Action
a98d108d2e chore: generate 2026-01-09 01:32:00 -05:00
Frank
2e875b2d65 wip: black 2026-01-09 01:32:00 -05:00
Frank
790baec41f wip: zen 2026-01-09 01:32:00 -05:00
Frank
52fbd16e08 wip: zen
wip: zen
2026-01-09 01:32:00 -05:00
GitHub Action
cf97633d7d chore: generate 2026-01-09 00:13:26 +00:00
Danil
3fe2e89d55 docs: Add Scaleway to provider docs (#7389) 2026-01-08 18:12:22 -06:00
Luke Parker
eb5c113cff ignore: add PR template (#7391) 2026-01-08 18:11:59 -06:00
72 changed files with 8507 additions and 408 deletions

3
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,3 @@
### What does this PR do?
### How did you verify your code works?

View File

@@ -9,6 +9,13 @@ on:
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
workflow_dispatch:
jobs:

View File

@@ -29,7 +29,7 @@ npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less frequently)
brew install opencode # macOS and Linux (official brew formula, updated less)
paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch

View File

@@ -195,3 +195,4 @@
| 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) |
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
| 2026-01-09 | 2,443,565 (+170,935) | 1,469,451 (+36,971) | 3,913,016 (+207,906) |

View File

@@ -286,8 +286,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.70",
"@opentui/solid": "0.1.70",
"@opentui/core": "0.1.72",
"@opentui/solid": "0.1.72",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -1201,21 +1201,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.70", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.70", "@opentui/core-darwin-x64": "0.1.70", "@opentui/core-linux-arm64": "0.1.70", "@opentui/core-linux-x64": "0.1.70", "@opentui/core-win32-arm64": "0.1.70", "@opentui/core-win32-x64": "0.1.70", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6cPAlbCnaiUUtQtvZNpkr0Xv8AdVAgJuy2VAwIsDN1pIv0zMpa0ZG+mr7afCGygw1eeDRveefrjfgFAB1r0SVw=="],
"@opentui/core": ["@opentui/core@0.1.72", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.72", "@opentui/core-darwin-x64": "0.1.72", "@opentui/core-linux-arm64": "0.1.72", "@opentui/core-linux-x64": "0.1.72", "@opentui/core-win32-arm64": "0.1.72", "@opentui/core-win32-x64": "0.1.72", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-l4WQzubBJ80Q0n77Lxuodjwwm8qj/sOa7IXxEAzzDDXY/7bsIhdSpVhRTt+KevBRlok5J+w/KMKYr8UzkA4/hA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.70", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rM8EnvW1tOAXWnp2Iy2M82I+ViSmRwUagx3v1/ni6N8GCcw/3mE0C6eB3sVlYNXVMwBEgiKpWFn85RCe4+qXQw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.72", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RoU48kOrhLZYDBiXaDu1LXS2bwRdlJlFle8eUQiqJjLRbMIY34J/srBuL0JnAS3qKW4J34NepUQa0l0/S43Q3w=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.70", "", { "os": "darwin", "cpu": "x64" }, "sha512-XdBgW+em8J+YGSUpaKF8/NxPjikJygK3dIkeMAw5xQ2lt7jXKxeM5MMmN/V4MfK3pLMtO56rLJlXaLH/h50uQA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.72", "", { "os": "darwin", "cpu": "x64" }, "sha512-hHUQw8i2LWPToRW1rjAiRqmNf34iJPS9ve9CJDygvFs5JOqUxN5yrfLfKfE+1bQjfFDHnpqW1HUk96iLhkPj8Q=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.70", "", { "os": "linux", "cpu": "arm64" }, "sha512-oSVWNMSOx0Na0M0LCqtWCxeh4SuLSK5lg8ZwVzsEoimIAxh0snp9nRUo/Qi8yD9BP0DSDmXuM/B3ONtzFaf0dw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.72", "", { "os": "linux", "cpu": "arm64" }, "sha512-63yml0OQ8tVa0JuDF9lBAWiChX6Q+iDO7lKv7c2n0352n/WyPr3iAgq4uSoH49HXuKeAXY/VwHGjvPzjXD/SDA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.70", "", { "os": "linux", "cpu": "x64" }, "sha512-WUrhukefMghcZ7sAjkxEy50vA6ii0X21xh7m8c4omXyYYfQXyDs25pNExB8cwoCrZEaC8RTlF4lRSNPIXsZKhA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.72", "", { "os": "linux", "cpu": "x64" }, "sha512-51veiQXNLvzDsFzsEvt71uK7WhiRe2DnvlJSGBSe6aRRHHxjCFYHzYi7t6bitJqtDTUj+EaMPbH81oZ6xy7tyg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.70", "", { "os": "win32", "cpu": "arm64" }, "sha512-p1K2VJXGmZqSV7mR61v7KJpT1Zth7DS99wEtaqqfK68OWt33K2XxLmGO0KD142R2JLfXu32NnRmBHxmVx8IjBA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.72", "", { "os": "win32", "cpu": "arm64" }, "sha512-1Ep6OcaYTy1RlLOln+LNN7DL1iNyLwLjG2M8aO0pVJKFvxeD5P7rdRzY065E4uhkHeJIHuduUqxvUjD0dyuwbw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.70", "", { "os": "win32", "cpu": "x64" }, "sha512-G6b8te1twMeDhjg1oZa0IcUjhOJZFCSdlQt+q5gu5vVtjCrIwAn9o7m5EwNMPakc31pDWUZ7v0ktgv0Xw1AQVA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.72", "", { "os": "win32", "cpu": "x64" }, "sha512-5QUv91UkOINlkEaPky3kaxmJvshcJMBAX7LZtIroduaKBGpWRA1aogNhPZzp+30WkvgOU7aOtUktAZuFXb9WdQ=="],
"@opentui/solid": ["@opentui/solid@0.1.70", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.70", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-8Cw/w4Of2OJhsFhcp/Wdj8cJRVaGvVsIiUoYiFtyToM01J4en0bg/vnbeZteyuZWeEtA4iz1/rSEQf7Dp+2FIQ=="],
"@opentui/solid": ["@opentui/solid@0.1.72", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.72", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-hytoLPboL/MTY/BQUnf/HlBuNXTVONney0X+PIQI82wT7kMx7+HHI2wnowpM3dyvA7l6NfORSud2cs9kIUBFBw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],

View File

@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-KjBAaI9Kv6huOmPvUbtyYsMhbScI91w1lOZyXpIWqI0="
"nodeModules": "sha256-+QM5BDFxzrm1HY5ealjCm7jIO1t/rpW1q4GGLViPMmA="
}

View File

@@ -14,36 +14,7 @@
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<!-- Theme preload script - applies cached theme to avoid FOUC -->
<script id="oc-theme-preload-script">
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()
</script>
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -0,0 +1,28 @@
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()

View File

@@ -38,9 +38,6 @@ declare global {
}
const defaultServerUrl = iife(() => {
const param = new URLSearchParams(document.location.search).get("url")
if (param) return param
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
if (import.meta.env.DEV)

View File

@@ -15,6 +15,7 @@ export function DialogSelectFile() {
const params = useParams()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
return (
<Dialog title="Select file">
<List
@@ -27,7 +28,7 @@ export function DialogSelectFile() {
const value = file.tab(path)
tabs().open(value)
file.load(path)
layout.review.open()
view().reviewPanel.open()
}
dialog.close()
}}

View File

@@ -76,7 +76,7 @@ export const ModelSelectorPopover: Component<{
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none">
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
</Kobalte.Content>

View File

@@ -20,6 +20,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const variant = createMemo(() => props.variant ?? "button")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const cost = createMemo(() => {
@@ -48,7 +49,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => {
if (!params.id) return
layout.review.open()
view().reviewPanel.open()
tabs().open("context")
tabs().setActive("context")
}

View File

@@ -43,6 +43,8 @@ export function SessionHeader() {
})
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey()))
function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
@@ -171,20 +173,24 @@ export function SessionHeader() {
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
@@ -197,11 +203,11 @@ export function SessionHeader() {
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={() => view().terminal.toggle()}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
@@ -211,7 +217,7 @@ export function SessionHeader() {
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>

View File

@@ -45,6 +45,8 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
let handleTextareaFocus: () => void
let handleTextareaBlur: () => void
let reconnect: number | undefined
let disposed = false
@@ -105,6 +107,7 @@ export const Terminal = (props: TerminalProps) => {
const t = new mod.Terminal({
cursorBlink: true,
cursorStyle: "bar",
fontSize: 14,
fontFamily: "IBM Plex Mono, monospace",
allowTransparency: true,
@@ -170,6 +173,17 @@ export const Terminal = (props: TerminalProps) => {
t.open(container)
container.addEventListener("pointerdown", handlePointerDown)
handleTextareaFocus = () => {
t.options.cursorBlink = true
}
handleTextareaBlur = () => {
t.options.cursorBlink = false
}
t.textarea?.addEventListener("focus", handleTextareaFocus)
t.textarea?.addEventListener("blur", handleTextareaBlur)
focusTerminal()
if (local.pty.buffer) {
@@ -242,6 +256,8 @@ export const Terminal = (props: TerminalProps) => {
window.removeEventListener("resize", handleResize)
}
container.removeEventListener("pointerdown", handlePointerDown)
term?.textarea?.removeEventListener("focus", handleTextareaFocus)
term?.textarea?.removeEventListener("blur", handleTextareaBlur)
const t = term
if (serializeAddon && props.onCleanup && t) {

View File

@@ -33,6 +33,8 @@ type SessionTabs = {
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
terminalOpened?: boolean
reviewPanelOpened?: boolean
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
@@ -53,11 +55,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
width: 280,
},
terminal: {
opened: false,
height: 280,
},
review: {
opened: true,
diffStyle: "split" as ReviewDiffStyle,
},
session: {
@@ -150,7 +150,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const current = store.sessionView[sessionKey]
const keep = meta.active ?? sessionKey
if (!current) {
setStore("sessionView", sessionKey, { scroll: next })
setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true })
prune(keep)
return
}
@@ -306,40 +306,20 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
terminal: {
opened: createMemo(() => store.terminal.opened),
open() {
setStore("terminal", "opened", true)
},
close() {
setStore("terminal", "opened", false)
},
toggle() {
setStore("terminal", "opened", (x) => !x)
},
height: createMemo(() => store.terminal.height),
resize(height: number) {
setStore("terminal", "height", height)
},
},
review: {
opened: createMemo(() => store.review?.opened ?? true),
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
setDiffStyle(diffStyle: ReviewDiffStyle) {
if (!store.review) {
setStore("review", { opened: true, diffStyle })
setStore("review", { diffStyle })
return
}
setStore("review", "diffStyle", diffStyle)
},
open() {
setStore("review", "opened", true)
},
close() {
setStore("review", "opened", false)
},
toggle() {
setStore("review", "opened", (x) => !x)
},
},
session: {
width: createMemo(() => store.session?.width ?? 600),
@@ -367,6 +347,33 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
touch(sessionKey)
scroll.seed(sessionKey)
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
const terminalOpened = createMemo(() => s().terminalOpened ?? false)
const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true)
function setTerminalOpened(next: boolean) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true })
return
}
const value = current.terminalOpened ?? false
if (value === next) return
setStore("sessionView", sessionKey, "terminalOpened", next)
}
function setReviewPanelOpened(next: boolean) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next })
return
}
const value = current.reviewPanelOpened ?? true
if (value === next) return
setStore("sessionView", sessionKey, "reviewPanelOpened", next)
}
return {
scroll(tab: string) {
return scroll.scroll(sessionKey, tab)
@@ -374,12 +381,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setScroll(tab: string, pos: SessionScroll) {
scroll.setScroll(sessionKey, tab, pos)
},
terminal: {
opened: terminalOpened,
open() {
setTerminalOpened(true)
},
close() {
setTerminalOpened(false)
},
toggle() {
setTerminalOpened(!terminalOpened())
},
},
reviewPanel: {
opened: reviewPanelOpened,
open() {
setReviewPanelOpened(true)
},
close() {
setReviewPanelOpened(false)
},
toggle() {
setReviewPanelOpened(!reviewPanelOpened())
},
},
review: {
open: createMemo(() => s().reviewOpen),
setOpen(open: string[]) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
setStore("sessionView", sessionKey, {
scroll: {},
terminalOpened: false,
reviewPanelOpened: true,
reviewOpen: open,
})
return
}

View File

@@ -377,7 +377,7 @@ export default function Page() {
})
createEffect(() => {
if (!layout.terminal.opened()) return
if (!view().terminal.opened()) return
if (!terminal.ready()) return
if (terminal.all().length !== 0) return
terminal.new()
@@ -440,7 +440,7 @@ export default function Page() {
category: "View",
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => layout.terminal.toggle(),
onSelect: () => view().terminal.toggle(),
},
{
id: "review.toggle",
@@ -448,7 +448,7 @@ export default function Page() {
description: "Show or hide the review panel",
category: "View",
keybind: "mod+shift+r",
onSelect: () => layout.review.toggle(),
onSelect: () => view().reviewPanel.toggle(),
},
{
id: "terminal.new",
@@ -720,7 +720,9 @@ export default function Page() {
const reviewTab = createMemo(() => hasReview() || tabs().active() === "review")
const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review")
const showTabs = createMemo(() => layout.review.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()))
const showTabs = createMemo(
() => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()),
)
const activeTab = createMemo(() => {
const active = tabs().active()
@@ -745,7 +747,7 @@ export default function Page() {
if (!id) return
if (!hasReview()) return
const wants = isDesktop() ? layout.review.opened() && activeTab() === "review" : store.mobileTab === "review"
const wants = isDesktop() ? view().reviewPanel.opened() && activeTab() === "review" : store.mobileTab === "review"
if (!wants) return
if (diffsReady()) return
@@ -1600,7 +1602,7 @@ export default function Page() {
</Show>
</div>
<Show when={isDesktop() && layout.terminal.opened()}>
<Show when={isDesktop() && view().terminal.opened()}>
<div
class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${layout.terminal.height()}px` }}
@@ -1612,7 +1614,7 @@ export default function Page() {
max={window.innerHeight * 0.6}
collapseThreshold={50}
onResize={layout.terminal.resize}
onCollapse={layout.terminal.close}
onCollapse={view().terminal.close}
/>
<Show
when={terminal.ready()}

View File

@@ -129,9 +129,9 @@ export default function Download() {
</code>
<CopyStatus />
</button>
<button data-component="cli-row" onClick={handleCopyClick("brew install opencode")}>
<button data-component="cli-row" onClick={handleCopyClick("brew install anomalyco/tap/opencode")}>
<code>
brew install <strong>opencode</strong>
brew install <strong>anomalyco/tap/opencode</strong>
</code>
<CopyStatus />
</button>

View File

@@ -140,7 +140,7 @@ export default function Home() {
<button data-copy data-slot="command" onClick={handleCopyClick}>
<span>
<span data-slot="protocol">brew install </span>
<span data-slot="highlight">opencode</span>
<span data-slot="highlight">anomalyco/tap/opencode</span>
</span>
<CopyStatus />
</button>

View File

@@ -1,12 +1,13 @@
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 { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.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"
import { Resource } from "@opencode-ai/console-resource"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -40,7 +41,7 @@ export async function POST(input: APIEvent) {
.where(eq(BillingTable.customerID, customerID))
})
}
if (body.type === "checkout.session.completed") {
if (body.type === "checkout.session.completed" && body.data.object.mode === "payment") {
const workspaceID = body.data.object.metadata?.workspaceID
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
const customerID = body.data.object.customer as string
@@ -103,85 +104,112 @@ export async function POST(input: APIEvent) {
})
})
}
if (body.type === "charge.refunded") {
if (body.type === "checkout.session.completed" && body.data.object.mode === "subscription") {
const workspaceID = body.data.object.custom_fields.find((f) => f.key === "workspaceid")?.text?.value
const amountInCents = body.data.object.amount_total as number
const customerID = body.data.object.customer as string
const paymentIntentID = body.data.object.payment_intent as string
if (!customerID) throw new Error("Customer ID not found")
if (!paymentIntentID) throw new Error("Payment ID not found")
const customerEmail = body.data.object.customer_details?.email as string
const invoiceID = body.data.object.invoice as string
const subscriptionID = body.data.object.subscription as string
const promoCode = body.data.object.discounts?.[0]?.promotion_code as string
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")
const amount = await Database.use((tx) =>
tx
.select({
amount: PaymentTable.amount,
})
.from(PaymentTable)
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
.then((rows) => rows[0]?.amount),
)
if (!amount) throw new Error("Payment not found")
await Database.transaction(async (tx) => {
await tx
.update(PaymentTable)
.set({
timeRefunded: new Date(body.created * 1000),
})
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${amount}`,
})
.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 (!amountInCents) throw new Error("Amount not found")
if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found")
// get payment id from invoice
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")
// get payment method for the payment intent
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
expand: ["payment_method"],
})
const paymentMethod = paymentIntent.payment_method
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
await Database.use((tx) =>
tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
paymentID,
invoiceID,
customerID,
}),
)
// get coupon id from promotion code
const couponID = await (async () => {
if (!promoCode) return
const coupon = await Billing.stripe().promotionCodes.retrieve(promoCode)
const couponID = coupon.coupon.id
if (!couponID) throw new Error("Coupon not found for promotion code")
return couponID
})()
// get user
await Actor.provide("system", { workspaceID }, async () => {
// look up current billing
const billing = await Billing.get()
if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
// Temporarily skip this check because during Black drop, user can checkout
// as a new customer
//if (billing.customerID !== customerID) throw new Error("Customer ID mismatch")
// Temporarily check the user to apply to. After Black drop, we will allow
// look up the user to apply to
const users = await Database.use((tx) =>
tx
.select({ id: UserTable.id, email: AuthTable.subject })
.from(UserTable)
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
)
const user = users.find((u) => u.email === customerEmail) ?? users[0]
if (!user) {
console.error(`Error: User with email ${customerEmail} not found in workspace ${workspaceID}`)
process.exit(1)
}
// set customer metadata
if (!billing?.customerID) {
await Billing.stripe().customers.update(customerID, {
metadata: {
workspaceID,
},
})
}
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
customerID,
subscriptionID,
subscriptionCouponID: couponID,
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
})
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.insert(SubscriptionTable).values({
workspaceID,
id: Identifier.create("subscription"),
userID: user.id,
})
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
paymentID,
invoiceID,
customerID,
enrichment: {
type: "subscription",
couponID,
},
})
})
})
}
if (body.type === "customer.subscription.created") {
const data = {
@@ -378,9 +406,111 @@ export async function POST(input: APIEvent) {
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(BillingTable)
.set({ subscriptionID: null, subscriptionCouponID: null })
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID))
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
})
}
if (body.type === "invoice.payment_succeeded") {
if (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")
// get coupon id from subscription
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscriptionID, {
expand: ["discounts"],
})
const couponID =
typeof subscriptionData.discounts[0] === "string"
? subscriptionData.discounts[0]
: subscriptionData.discounts[0]?.coupon?.id
// get payment id from invoice
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"],
})
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
if (!paymentID) {
// payment id can be undefined when using coupon
if (!couponID) 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,
enrichment: {
type: "subscription",
couponID,
},
}),
)
}
}
if (body.type === "charge.refunded") {
const customerID = body.data.object.customer as string
const paymentIntentID = body.data.object.payment_intent as string
if (!customerID) throw new Error("Customer ID not found")
if (!paymentIntentID) throw new Error("Payment ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({
workspaceID: BillingTable.workspaceID,
})
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found")
const amount = await Database.use((tx) =>
tx
.select({
amount: PaymentTable.amount,
})
.from(PaymentTable)
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
.then((rows) => rows[0]?.amount),
)
if (!amount) throw new Error("Payment not found")
await Database.transaction(async (tx) => {
await tx
.update(PaymentTable)
.set({
timeRefunded: new Date(body.created * 1000),
})
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${amount}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
})
}
})()

View File

@@ -1,6 +1,6 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
import { For, Show } from "solid-js"
import { For, Match, Show, Switch } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css"
@@ -77,7 +77,8 @@ export function PaymentSection() {
<For each={payments()!}>
{(payment) => {
const date = new Date(payment.timeCreated)
const isCredit = !payment.paymentID
const amount =
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
return (
<tr>
<td data-slot="payment-date" title={formatDateUTC(date)}>
@@ -85,13 +86,14 @@ export function PaymentSection() {
</td>
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
${((payment.amount ?? 0) / 100000000).toFixed(2)}
{isCredit ? " (credit)" : ""}
${((amount ?? 0) / 100000000).toFixed(2)}
<Switch>
<Match when={payment.enrichment?.type === "credit"}> (credit)</Match>
<Match when={payment.enrichment?.type === "subscription"}> (subscription)</Match>
</Switch>
</td>
<td data-slot="payment-receipt">
{isCredit ? (
<span>-</span>
) : (
{payment.paymentID ? (
<button
onClick={async () => {
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
@@ -103,6 +105,8 @@ export function PaymentSection() {
>
View
</button>
) : (
<span>-</span>
)}
</td>
</tr>

View File

@@ -44,6 +44,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
eq(UsageTable.workspaceID, workspaceID),
gte(UsageTable.timeCreated, startDate),
lte(UsageTable.timeCreated, endDate),
or(isNull(UsageTable.enrichment), sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') != 'sub'`),
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)

View File

@@ -1,8 +1,9 @@
import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { BillingTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { BillingTable, SubscriptionTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { getWeekBounds } from "@opencode-ai/console-core/util/date.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -415,11 +416,11 @@ export async function handler(
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
},
subscription: {
timeSubscribed: UserTable.timeSubscribed,
subIntervalUsage: UserTable.subIntervalUsage,
subMonthlyUsage: UserTable.subMonthlyUsage,
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated,
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated,
id: SubscriptionTable.id,
rollingUsage: SubscriptionTable.rollingUsage,
fixedUsage: SubscriptionTable.fixedUsage,
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
},
provider: {
credentials: ProviderTable.credentials,
@@ -440,6 +441,14 @@ export async function handler(
)
: sql`false`,
)
.leftJoin(
SubscriptionTable,
and(
eq(SubscriptionTable.workspaceID, KeyTable.workspaceID),
eq(SubscriptionTable.userID, KeyTable.userID),
isNull(SubscriptionTable.timeDeleted),
),
)
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
)
@@ -448,7 +457,7 @@ export async function handler(
logger.metric({
api_key: data.apiKey,
workspace: data.workspaceID,
isSubscription: data.subscription.timeSubscribed ? true : false,
isSubscription: data.subscription ? true : false,
})
return {
@@ -456,7 +465,7 @@ export async function handler(
workspaceID: data.workspaceID,
billing: data.billing,
user: data.user,
subscription: data.subscription.timeSubscribed ? data.subscription : undefined,
subscription: data.subscription,
provider: data.provider,
isFree: FREE_WORKSPACES.includes(data.workspaceID),
isDisabled: !!data.timeDisabled,
@@ -484,23 +493,11 @@ export async function handler(
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)
// Check weekly limit
if (sub.fixedUsage && sub.timeFixedUpdated) {
const week = getWeekBounds(now)
if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) {
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
retryAfter,
@@ -508,14 +505,12 @@ export async function handler(
}
}
// 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)
// Check rolling limit
if (sub.rollingUsage && sub.timeRollingUpdated) {
const rollingWindowMs = black.rollingWindow * 3600 * 1000
const windowStart = new Date(now.getTime() - rollingWindowMs)
if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) {
const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
retryAfter,
@@ -661,38 +656,39 @@ export async function handler(
.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),
)
const black = BlackData.get()
const week = getWeekBounds(new Date())
const rollingWindowSeconds = black.rollingWindow * 3600
return [
db
.update(UserTable)
.update(SubscriptionTable)
.set({
subMonthlyUsage: sql`
fixedUsage: sql`
CASE
WHEN ${UserTable.timeSubMonthlyUsageUpdated} >= ${cycleStart} AND ${UserTable.timeSubMonthlyUsageUpdated} < ${cycleEnd} THEN ${UserTable.subMonthlyUsage} + ${cost}
WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost}
ELSE ${cost}
END
`,
timeSubMonthlyUsageUpdated: sql`now()`,
subIntervalUsage: sql`
timeFixedUpdated: sql`now()`,
rollingUsage: sql`
CASE
WHEN FLOOR(UNIX_TIMESTAMP(${UserTable.timeSubIntervalUsageUpdated}) / (${BlackData.get().intervalLength} * 3600)) = FLOOR(UNIX_TIMESTAMP(now()) / (${BlackData.get().intervalLength} * 3600)) THEN ${UserTable.subIntervalUsage} + ${cost}
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost}
ELSE ${cost}
END
`,
timeSubIntervalUsageUpdated: sql`now()`,
timeRollingUpdated: sql`
CASE
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.timeRollingUpdated}
ELSE now()
END
`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
.where(
and(
eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
eq(SubscriptionTable.userID, authInfo.user.id),
),
),
]
})()
: [

View File

@@ -0,0 +1,13 @@
CREATE TABLE `subscription` (
`id` varchar(30) NOT NULL,
`workspace_id` varchar(30) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
`user_id` varchar(30) NOT NULL,
`rolling_usage` bigint,
`fixed_usage` bigint,
`time_rolling_updated` timestamp(3),
`time_fixed_updated` timestamp(3),
CONSTRAINT `subscription_workspace_id_id_pk` PRIMARY KEY(`workspace_id`,`id`)
);

View File

@@ -0,0 +1,6 @@
CREATE INDEX `workspace_user_id` ON `subscription` (`workspace_id`,`user_id`);--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `time_subscribed`;--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `sub_interval_usage`;--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `sub_monthly_usage`;--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `sub_time_interval_usage_updated`;--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `sub_time_monthly_usage_updated`;

View File

@@ -0,0 +1,2 @@
DROP INDEX `workspace_user_id` ON `subscription`;--> statement-breakpoint
ALTER TABLE `subscription` ADD CONSTRAINT `workspace_user_id` UNIQUE(`workspace_id`,`user_id`);

View File

@@ -0,0 +1 @@
ALTER TABLE `billing` ADD `subscription_coupon_id` varchar(28);

View File

@@ -0,0 +1 @@
ALTER TABLE `payment` ADD `enrichment` json;

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

File diff suppressed because it is too large Load Diff

View File

@@ -323,6 +323,41 @@
"when": 1767765497502,
"tag": "0045_cuddly_diamondback",
"breakpoints": true
},
{
"idx": 46,
"version": "5",
"when": 1767912262458,
"tag": "0046_charming_black_bolt",
"breakpoints": true
},
{
"idx": 47,
"version": "5",
"when": 1767916965243,
"tag": "0047_huge_omega_red",
"breakpoints": true
},
{
"idx": 48,
"version": "5",
"when": 1767917785224,
"tag": "0048_mean_frank_castle",
"breakpoints": true
},
{
"idx": 49,
"version": "5",
"when": 1767922954153,
"tag": "0049_noisy_domino",
"breakpoints": true
},
{
"idx": 50,
"version": "5",
"when": 1767931290031,
"tag": "0050_bumpy_mephistopheles",
"breakpoints": true
}
]
}

View File

@@ -1,8 +1,11 @@
import { Database, eq, sql, inArray } from "../src/drizzle/index.js"
import { Database, and, eq, 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, UsageTable } from "../src/schema/billing.sql.js"
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
import { BlackData } from "../src/black.js"
import { centsToMicroCents } from "../src/util/price.js"
import { getWeekBounds } from "../src/util/date.js"
// get input from command line
const identifier = process.argv[2]
@@ -56,6 +59,44 @@ async function printWorkspace(workspaceID: string) {
printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
await printTable("Users", (tx) =>
tx
.select({
authEmail: AuthTable.subject,
inviteEmail: UserTable.email,
role: UserTable.role,
timeSeen: UserTable.timeSeen,
monthlyLimit: UserTable.monthlyLimit,
monthlyUsage: UserTable.monthlyUsage,
timeDeleted: UserTable.timeDeleted,
fixedUsage: SubscriptionTable.fixedUsage,
rollingUsage: SubscriptionTable.rollingUsage,
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
timeSubscriptionCreated: SubscriptionTable.timeCreated,
})
.from(UserTable)
.leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
.where(eq(UserTable.workspaceID, workspace.id))
.then((rows) =>
rows.map((row) => {
const subStatus = getSubscriptionStatus(row)
return {
email: (row.timeDeleted ? "❌ " : "") + (row.authEmail ?? row.inviteEmail),
role: row.role,
timeSeen: formatDate(row.timeSeen),
monthly: formatMonthlyUsage(row.monthlyUsage, row.monthlyLimit),
subscribed: formatDate(row.timeSubscriptionCreated),
subWeekly: subStatus.weekly,
subRolling: subStatus.rolling,
rateLimited: subStatus.rateLimited,
retryIn: subStatus.retryIn,
}
}),
),
)
await printTable("Billing", (tx) =>
tx
.select({
@@ -124,6 +165,80 @@ async function printWorkspace(workspaceID: string) {
)
}
function formatMicroCents(value: number | null | undefined) {
if (value === null || value === undefined) return null
return `$${(value / 100000000).toFixed(2)}`
}
function formatDate(value: Date | null | undefined) {
if (!value) return null
return value.toISOString().split("T")[0]
}
function formatMonthlyUsage(usage: number | null | undefined, limit: number | null | undefined) {
const usageText = formatMicroCents(usage) ?? "$0.00"
if (limit === null || limit === undefined) return `${usageText} / no limit`
return `${usageText} / $${limit.toFixed(2)}`
}
function 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`
}
function getSubscriptionStatus(row: {
timeSubscriptionCreated: Date | null
fixedUsage: number | null
rollingUsage: number | null
timeFixedUpdated: Date | null
timeRollingUpdated: Date | null
}) {
if (!row.timeSubscriptionCreated) {
return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
}
const black = BlackData.get()
const now = new Date()
const week = getWeekBounds(now)
const fixedLimit = black.fixedLimit ? centsToMicroCents(black.fixedLimit * 100) : null
const rollingLimit = black.rollingLimit ? centsToMicroCents(black.rollingLimit * 100) : null
const rollingWindowMs = (black.rollingWindow ?? 5) * 3600 * 1000
// Calculate current weekly usage (reset if outside current week)
const currentWeekly =
row.fixedUsage && row.timeFixedUpdated && row.timeFixedUpdated >= week.start ? row.fixedUsage : 0
// Calculate current rolling usage
const windowStart = new Date(now.getTime() - rollingWindowMs)
const currentRolling =
row.rollingUsage && row.timeRollingUpdated && row.timeRollingUpdated >= windowStart ? row.rollingUsage : 0
// Check rate limiting
const isWeeklyLimited = fixedLimit !== null && currentWeekly >= fixedLimit
const isRollingLimited = rollingLimit !== null && currentRolling >= rollingLimit
let retryIn: string | null = null
if (isWeeklyLimited) {
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
retryIn = formatRetryTime(retryAfter)
} else if (isRollingLimited && row.timeRollingUpdated) {
const retryAfter = Math.ceil((row.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
retryIn = formatRetryTime(retryAfter)
}
return {
weekly: fixedLimit !== null ? `${formatMicroCents(currentWeekly)} / $${black.fixedLimit}` : null,
rolling: rollingLimit !== null ? `${formatMicroCents(currentRolling)} / $${black.rollingLimit}` : null,
rateLimited: isWeeklyLimited || isRollingLimited ? "yes" : "no",
retryIn,
}
}
function printHeader(title: string) {
console.log()
console.log("─".repeat(title.length))

View File

@@ -1,35 +1,35 @@
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 { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { Identifier } from "../src/identifier.js"
import { centsToMicroCents } from "../src/util/price.js"
import { AuthTable } from "../src/schema/auth.sql.js"
const workspaceID = process.argv[2]
const email = process.argv[3]
console.log(`Onboarding workspace ${workspaceID} for email ${email}`)
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) {
const customers = await Billing.stripe().customers.list({ email, limit: 10, expand: ["data.subscriptions"] })
if (!customers.data) {
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`)
const customer = customers.data.find((c) => c.subscriptions?.data[0]?.items.data[0]?.price.unit_amount === 20000)
if (!customer) {
console.error(`Error: No Stripe customer found for email ${email} with $200 subscription`)
process.exit(1)
}
const customerID = customer.id
const subscription = customer.subscriptions!.data[0]
const subscriptionID = subscription.id
// Validate the subscription is $200
@@ -39,6 +39,12 @@ if (amountInCents !== 20000) {
process.exit(1)
}
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscription.id, { expand: ["discounts"] })
const couponID =
typeof subscriptionData.discounts[0] === "string"
? subscriptionData.discounts[0]
: subscriptionData.discounts[0]?.coupon?.id
// Check if subscription is already tied to another workspace
const existingSubscription = await Database.use((tx) =>
tx
@@ -90,29 +96,21 @@ const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.re
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) =>
// Look up the user in the workspace
const users = 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]),
.select({ id: UserTable.id, email: AuthTable.subject })
.from(UserTable)
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
)
if (!auth) {
console.error(`Error: No user found with email ${email}`)
if (users.length === 0) {
console.error(`Error: No users found in workspace ${workspaceID}`)
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]),
)
const user = users.length === 1 ? users[0] : users.find((u) => u.email === email)
if (!user) {
console.error(`Error: User with email ${email} is not a member of workspace ${workspaceID}`)
console.error(`Error: User with email ${email} not found in workspace ${workspaceID}`)
process.exit(1)
}
@@ -130,19 +128,19 @@ await Database.transaction(async (tx) => {
.set({
customerID,
subscriptionID,
subscriptionCouponID: couponID,
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 subscription table
await tx.insert(SubscriptionTable).values({
workspaceID,
id: Identifier.create("subscription"),
userID: user.id,
})
// Create a row in payments table
await tx.insert(PaymentTable).values({
@@ -152,6 +150,10 @@ await Database.transaction(async (tx) => {
customerID,
invoiceID,
paymentID,
enrichment: {
type: "subscription",
couponID,
},
})
})

View File

@@ -171,6 +171,9 @@ export namespace Billing {
workspaceID,
id: Identifier.create("payment"),
amount: amountInMicroCents,
enrichment: {
type: "credit",
},
})
})
return amountInMicroCents

View File

@@ -4,9 +4,9 @@ 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(),
fixedLimit: z.number().int(),
rollingLimit: z.number().int(),
rollingWindow: z.number().int(),
})
export const validate = fn(Schema, (input) => {

View File

@@ -11,6 +11,7 @@ export namespace Identifier {
model: "mod",
payment: "pay",
provider: "prv",
subscription: "sub",
usage: "usg",
user: "usr",
workspace: "wrk",

View File

@@ -22,6 +22,7 @@ export const BillingTable = mysqlTable(
timeReloadError: utc("time_reload_error"),
timeReloadLockedTill: utc("time_reload_locked_till"),
subscriptionID: varchar("subscription_id", { length: 28 }),
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
},
(table) => [
...workspaceIndexes(table),
@@ -30,6 +31,20 @@ export const BillingTable = mysqlTable(
],
)
export const SubscriptionTable = mysqlTable(
"subscription",
{
...workspaceColumns,
...timestamps,
userID: ulid("user_id").notNull(),
rollingUsage: bigint("rolling_usage", { mode: "number" }),
fixedUsage: bigint("fixed_usage", { mode: "number" }),
timeRollingUpdated: utc("time_rolling_updated"),
timeFixedUpdated: utc("time_fixed_updated"),
},
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
)
export const PaymentTable = mysqlTable(
"payment",
{
@@ -40,6 +55,15 @@ export const PaymentTable = mysqlTable(
paymentID: varchar("payment_id", { length: 255 }),
amount: bigint("amount", { mode: "number" }).notNull(),
timeRefunded: utc("time_refunded"),
enrichment: json("enrichment").$type<
| {
type: "subscription"
couponID?: string
}
| {
type: "credit"
}
>(),
},
(table) => [...workspaceIndexes(table)],
)

View File

@@ -18,12 +18,6 @@ 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),

View File

@@ -0,0 +1,9 @@
export function getWeekBounds(date: Date) {
const dayOfWeek = date.getUTCDay()
const start = new Date(date)
start.setUTCDate(date.getUTCDate() - dayOfWeek + 1)
start.setUTCHours(0, 0, 0, 0)
const end = new Date(start)
end.setUTCDate(start.getUTCDate() + 7)
return { start, end }
}

View File

@@ -13,36 +13,7 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script">
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()
</script>
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -1,3 +1,5 @@
use tauri::Manager;
const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode";
@@ -9,9 +11,10 @@ fn get_cli_install_path() -> Option<std::path::PathBuf> {
})
}
pub fn get_sidecar_path() -> std::path::PathBuf {
tauri::utils::platform::current_exe()
.expect("Failed to get current exe")
pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf {
// Get binary with symlinks support
tauri::process::current_binary(&app.env())
.expect("Failed to get current binary")
.parent()
.expect("Failed to get parent dir")
.join("opencode-cli")
@@ -26,12 +29,12 @@ fn is_cli_installed() -> bool {
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
#[tauri::command]
pub fn install_cli() -> Result<String, String> {
pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
if cfg!(not(unix)) {
return Err("CLI installation is only supported on macOS & Linux".to_string());
}
let sidecar = get_sidecar_path();
let sidecar = get_sidecar_path(&app);
if !sidecar.exists() {
return Err("Sidecar binary not found".to_string());
}
@@ -108,7 +111,7 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
cli_version, app_version
);
install_cli()?;
install_cli(app)?;
println!("Synced installed CLI");

View File

@@ -129,7 +129,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
#[cfg(not(target_os = "windows"))]
let (mut rx, child) = {
let sidecar = get_sidecar_path();
let sidecar = get_sidecar_path(app);
let shell = get_user_shell();
app.shell()
.command(&shell)

View File

@@ -6,6 +6,7 @@ const host = process.env.TAURI_DEV_HOST
// https://vite.dev/config/
export default defineConfig({
plugins: [appPlugin],
publicDir: "../app/public",
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors

View File

@@ -81,8 +81,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.70",
"@opentui/solid": "0.1.70",
"@opentui/core": "0.1.72",
"@opentui/solid": "0.1.72",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",

View File

@@ -3,6 +3,8 @@ import { Global } from "../global"
import fs from "fs/promises"
import z from "zod"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export namespace Auth {
export const Oauth = z
.object({

View File

@@ -341,8 +341,6 @@ export const AuthLoginCommand = cmd({
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
)
prompts.outro("Done")
return
}
if (provider === "opencode") {

View File

@@ -33,76 +33,82 @@ export function DialogModel(props: { providerID?: string }) {
const options = createMemo(() => {
const q = query()
const favorites = showExtra() ? local.model.favorite() : []
const needle = q.trim()
const showSections = showExtra() && needle.length === 0
const favorites = connected() ? local.model.favorite() : []
const recents = local.model.recent()
const recentList = showExtra()
const recentList = showSections
? recents.filter(
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
)
: []
const favoriteOptions = favorites.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Favorites",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
const favoriteOptions = showSections
? favorites.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
title: model.name ?? item.modelID,
description: provider.name,
category: "Favorites",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []
const recentOptions = recentList.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
const recentOptions = showSections
? recentList.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []
const providerOptions = pipe(
sync.data.provider,
@@ -145,6 +151,7 @@ export function DialogModel(props: { providerID?: string }) {
}
}),
filter((x) => {
if (!showSections) return true
const value = x.value
const inFavorites = favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
@@ -177,16 +184,11 @@ export function DialogModel(props: { providerID?: string }) {
)
: []
// Apply fuzzy filtering to each section separately, maintaining section order
if (q) {
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj)
const filteredRecents = fuzzysort
.go(q, recentOptions, { keys: ["title"] })
.map((x) => x.obj)
.slice(0, 5)
const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj)
return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular]
// Search shows a single merged list (favorites inline)
if (needle) {
const filteredProviders = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredPopular = fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj)
return [...filteredProviders, ...filteredPopular]
}
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]

View File

@@ -178,6 +178,8 @@ export namespace Config {
result.compaction = { ...result.compaction, prune: false }
}
result.plugin = deduplicatePlugins(result.plugin ?? [])
return {
config: result,
directories,
@@ -332,6 +334,58 @@ export namespace Config {
return plugins
}
/**
* Extracts a canonical plugin name from a plugin specifier.
* - For file:// URLs: extracts filename without extension
* - For npm packages: extracts package name without version
*
* @example
* getPluginName("file:///path/to/plugin/foo.js") // "foo"
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
*/
export function getPluginName(plugin: string): string {
if (plugin.startsWith("file://")) {
return path.parse(new URL(plugin).pathname).name
}
const lastAt = plugin.lastIndexOf("@")
if (lastAt > 0) {
return plugin.substring(0, lastAt)
}
return plugin
}
/**
* Deduplicates plugins by name, with later entries (higher priority) winning.
* Priority order (highest to lowest):
* 1. Local plugin/ directory
* 2. Local opencode.json
* 3. Global plugin/ directory
* 4. Global opencode.json
*
* Since plugins are added in low-to-high priority order,
* we reverse, deduplicate (keeping first occurrence), then restore order.
*/
export function deduplicatePlugins(plugins: string[]): string[] {
// seenNames: canonical plugin names for duplicate detection
// e.g., "oh-my-opencode", "@scope/pkg"
const seenNames = new Set<string>()
// uniqueSpecifiers: full plugin specifiers to return
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
const uniqueSpecifiers: string[] = []
for (const specifier of plugins.toReversed()) {
const name = getPluginName(specifier)
if (!seenNames.has(name)) {
seenNames.add(name)
uniqueSpecifiers.push(specifier)
}
}
return uniqueSpecifiers.toReversed()
}
export const McpLocal = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),

View File

@@ -33,7 +33,7 @@ await Promise.all([
fs.mkdir(Global.Path.bin, { recursive: true }),
])
const CACHE_VERSION = "14"
const CACHE_VERSION = "16"
const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text()

View File

@@ -99,14 +99,16 @@ const cli = yargs(hideBin(process.argv))
.command(GithubCommand)
.command(PrCommand)
.command(SessionCommand)
.fail((msg) => {
.fail((msg, err) => {
if (
msg?.startsWith("Unknown argument") ||
msg?.startsWith("Not enough non-option arguments") ||
msg?.startsWith("Invalid values:")
) {
if (err) throw err
cli.showHelp("log")
}
if (err) throw err
process.exit(1)
})
.strict()

View File

@@ -0,0 +1,417 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Log } from "../util/log"
import { OAUTH_DUMMY_KEY } from "../auth"
const log = Log.create({ service: "plugin.codex" })
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455
interface PkceCodes {
verifier: string
challenge: string
}
async function generatePKCE(): Promise<PkceCodes> {
const verifier = generateRandomString(43)
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const hash = await crypto.subtle.digest("SHA-256", data)
const challenge = base64UrlEncode(hash)
return { verifier, challenge }
}
function generateRandomString(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const bytes = crypto.getRandomValues(new Uint8Array(length))
return Array.from(bytes)
.map((b) => chars[b % chars.length])
.join("")
}
function base64UrlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
const binary = String.fromCharCode(...bytes)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function generateState(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
}
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: redirectUri,
scope: "openid profile email offline_access",
code_challenge: pkce.challenge,
code_challenge_method: "S256",
id_token_add_organizations: "true",
codex_cli_simplified_flow: "true",
state,
originator: "opencode",
})
return `${ISSUER}/oauth/authorize?${params.toString()}`
}
interface TokenResponse {
id_token: string
access_token: string
refresh_token: string
expires_in?: number
}
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: CLIENT_ID,
code_verifier: pkce.verifier,
}).toString(),
})
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`)
}
return response.json()
}
async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}).toString(),
})
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status}`)
}
return response.json()
}
const HTML_SUCCESS = `<!DOCTYPE html>
<html>
<head>
<title>OpenCode - Codex Authorization Successful</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.container { text-align: center; padding: 2rem; }
h1 { color: #4ade80; margin-bottom: 1rem; }
p { color: #aaa; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>`
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<html>
<head>
<title>OpenCode - Codex Authorization Failed</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.container { text-align: center; padding: 2rem; }
h1 { color: #f87171; margin-bottom: 1rem; }
p { color: #aaa; }
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${error}</div>
</div>
</body>
</html>`
interface PendingOAuth {
pkce: PkceCodes
state: string
resolve: (tokens: TokenResponse) => void
reject: (error: Error) => void
}
let oauthServer: ReturnType<typeof Bun.serve> | undefined
let pendingOAuth: PendingOAuth | undefined
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
if (oauthServer) {
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
oauthServer = Bun.serve({
port: OAUTH_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
if (error) {
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const current = pendingOAuth
pendingOAuth = undefined
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
}
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
pendingOAuth = undefined
return new Response("Login cancelled", { status: 200 })
}
return new Response("Not found", { status: 404 })
},
})
log.info("codex oauth server started", { port: OAUTH_PORT })
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
function stopOAuthServer() {
if (oauthServer) {
oauthServer.stop()
oauthServer = undefined
log.info("codex oauth server stopped")
}
}
function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => {
if (pendingOAuth) {
pendingOAuth = undefined
reject(new Error("OAuth callback timeout - authorization took too long"))
}
},
5 * 60 * 1000,
) // 5 minute timeout
pendingOAuth = {
pkce,
state,
resolve: (tokens) => {
clearTimeout(timeout)
resolve(tokens)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
},
}
})
}
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
return {
auth: {
provider: "openai",
async loader(getAuth, provider) {
const auth = await getAuth()
if (auth.type !== "oauth") return {}
// Filter models to only allowed Codex models for OAuth
const allowedModels = new Set(["gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2", "gpt-5.2-codex"])
for (const modelId of Object.keys(provider.models)) {
if (!allowedModels.has(modelId)) {
delete provider.models[modelId]
}
}
if (!provider.models["gpt-5.2-codex"]) {
provider.models["gpt-5.2-codex"] = {
id: "gpt-5.2-codex",
providerID: "openai",
api: {
id: "gpt-5.2-codex",
url: "https://chatgpt.com/backend-api/codex",
npm: "@ai-sdk/openai",
},
name: "GPT-5.2 Codex",
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 400000, output: 128000 },
status: "active",
options: {},
headers: {},
}
}
// Zero out costs for Codex (included with ChatGPT subscription)
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
}
}
return {
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
// Remove dummy API key authorization header
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.delete("authorization")
init.headers.delete("Authorization")
} else if (Array.isArray(init.headers)) {
init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization")
} else {
delete init.headers["authorization"]
delete init.headers["Authorization"]
}
}
const currentAuth = await getAuth()
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
// Check if token needs refresh
if (!currentAuth.access || currentAuth.expires < Date.now()) {
log.info("refreshing codex access token")
const tokens = await refreshAccessToken(currentAuth.refresh)
await input.client.auth.set({
path: { id: "codex" },
body: {
type: "oauth",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
},
})
currentAuth.access = tokens.access_token
}
// Build headers
const headers = new Headers()
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((value, key) => headers.set(key, value))
} else if (Array.isArray(init.headers)) {
for (const [key, value] of init.headers) {
if (value !== undefined) headers.set(key, String(value))
}
} else {
for (const [key, value] of Object.entries(init.headers)) {
if (value !== undefined) headers.set(key, String(value))
}
}
}
// Set authorization header with access token
headers.set("authorization", `Bearer ${currentAuth.access}`)
// Rewrite URL to Codex endpoint
let url: URL
if (typeof requestInput === "string") {
url = new URL(requestInput)
} else if (requestInput instanceof URL) {
url = requestInput
} else {
url = new URL(requestInput.url)
}
// If this is a messages/responses request, redirect to Codex endpoint
if (url.pathname.includes("/v1/responses") || url.pathname.includes("/chat/completions")) {
url = new URL(CODEX_API_ENDPOINT)
}
return fetch(url, {
...init,
headers,
})
},
}
},
methods: [
{
label: "ChatGPT Pro/Plus",
type: "oauth",
authorize: async () => {
const { redirectUri } = await startOAuthServer()
const pkce = await generatePKCE()
const state = generateState()
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
const callbackPromise = waitForOAuthCallback(pkce, state)
return {
url: authUrl,
instructions: "Complete authorization in your browser. This window will close automatically.",
method: "auto" as const,
callback: async () => {
const tokens = await callbackPromise
stopOAuthServer()
return {
type: "success" as const,
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
}
},
}
},
},
],
},
}
}

View File

@@ -7,11 +7,15 @@ import { Server } from "../server/server"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
const BUILTIN = ["opencode-copilot-auth@0.0.9", "opencode-anthropic-auth@0.0.5"]
const BUILTIN = ["opencode-copilot-auth@0.0.11", "opencode-anthropic-auth@0.0.8"]
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
const state = Instance.state(async () => {
const client = createOpencodeClient({
@@ -20,7 +24,7 @@ export namespace Plugin {
fetch: async (...args) => Server.App().fetch(...args),
})
const config = await Config.get()
const hooks = []
const hooks: Hooks[] = []
const input: PluginInput = {
client,
project: Instance.project,
@@ -29,11 +33,23 @@ export namespace Plugin {
serverUrl: Server.url(),
$: Bun.$,
}
// Load internal plugins first
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
for (const plugin of INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const init = await plugin(input)
hooks.push(init)
}
}
const plugins = [...(config.plugin ?? [])]
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
plugins.push(...BUILTIN)
}
for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
if (plugin.includes("opencode-openai-codex-auth")) continue
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const lastAtIndex = plugin.lastIndexOf("@")

View File

@@ -852,6 +852,7 @@ export namespace Provider {
if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat"))
delete provider.models[modelID]
if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
if (model.status === "deprecated") delete provider.models[modelID]
if (
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
(configProvider?.whitelist && !configProvider.whitelist.includes(modelID))

View File

@@ -74,6 +74,7 @@ export namespace Server {
const app = new Hono()
export const App: () => Hono = lazy(
() =>
// TODO: Break server.ts into smaller route files to fix type inference
app
.onError((err, c) => {
log.error("failed", {

View File

@@ -151,12 +151,19 @@ export namespace Session {
directory: Instance.directory,
})
const msgs = await messages({ sessionID: input.sessionID })
const idMap = new Map<string, string>()
for (const msg of msgs) {
if (input.messageID && msg.info.id >= input.messageID) break
const newID = Identifier.ascending("message")
idMap.set(msg.info.id, newID)
const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined
const cloned = await updateMessage({
...msg.info,
sessionID: session.id,
id: Identifier.ascending("message"),
id: newID,
...(parentID && { parentID }),
})
for (const part of msg.parts) {

View File

@@ -1,3 +1,5 @@
import os from "os"
import { Installation } from "@/installation"
import { Provider } from "@/provider/provider"
import { Log } from "@/util/log"
import {
@@ -19,6 +21,7 @@ import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag"
import { PermissionNext } from "@/permission/next"
import { Auth } from "@/auth"
export namespace LLM {
const log = Log.create({ service: "llm" })
@@ -82,12 +85,24 @@ export namespace LLM {
}
const provider = await Provider.getProvider(input.model.providerID)
const auth = await Auth.get(input.model.providerID)
const isCodex = provider.id === "openai" && auth?.type === "oauth"
const variant =
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options(input.model, input.sessionID, provider.options)
const options = pipe(base, mergeDeep(input.model.options), mergeDeep(input.agent.options), mergeDeep(variant))
const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isCodex) {
options.instructions = SystemPrompt.instructions()
options.store = false
}
const params = await Plugin.trigger(
"chat.params",
@@ -108,16 +123,14 @@ export namespace LLM {
},
)
l.info("params", {
params,
})
const maxOutputTokens = ProviderTransform.maxOutputTokens(
input.model.api.npm,
params.options,
input.model.limit.output,
OUTPUT_TOKEN_MAX,
)
const maxOutputTokens = isCodex
? undefined
: ProviderTransform.maxOutputTokens(
input.model.api.npm,
params.options,
input.model.limit.output,
OUTPUT_TOKEN_MAX,
)
const tools = await resolveTools(input)
@@ -157,6 +170,13 @@ export namespace LLM {
maxOutputTokens,
abortSignal: input.abort,
headers: {
...(isCodex
? {
originator: "opencode",
"User-Agent": `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
session_id: input.sessionID,
}
: undefined),
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
@@ -169,12 +189,19 @@ export namespace LLM {
},
maxRetries: input.retries ?? 0,
messages: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...(isCodex
? [
{
role: "user",
content: system.join("\n\n"),
} as ModelMessage,
]
: system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
)),
...input.messages,
],
model: wrapLanguageModel({

View File

@@ -0,0 +1 @@
You are a coding agent running in the opencode, a terminal-based coding assistant. opencode is an open source project. You are expected to be precise, safe, and helpful.

View File

@@ -14,6 +14,7 @@ import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
import PROMPT_CODEX from "./prompt/codex.txt"
import PROMPT_CODEX_INSTRUCTIONS from "./prompt/codex_header.txt"
import type { Provider } from "@/provider/provider"
import { Flag } from "@/flag/flag"
@@ -23,6 +24,10 @@ export namespace SystemPrompt {
return []
}
export function instructions() {
return PROMPT_CODEX_INSTRUCTIONS.trim()
}
export function provider(model: Provider.Model) {
if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))

View File

@@ -1,4 +1,4 @@
import { test, expect, mock, afterEach } from "bun:test"
import { test, expect, describe, mock } from "bun:test"
import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
@@ -1145,3 +1145,91 @@ test("project config overrides remote well-known config", async () => {
Auth.all = originalAuthAll
}
})
describe("getPluginName", () => {
test("extracts name from file:// URL", () => {
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar")
expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin")
})
test("extracts name from npm package with version", () => {
expect(Config.getPluginName("oh-my-opencode@2.4.3")).toBe("oh-my-opencode")
expect(Config.getPluginName("some-plugin@1.0.0")).toBe("some-plugin")
expect(Config.getPluginName("plugin@latest")).toBe("plugin")
})
test("extracts name from scoped npm package", () => {
expect(Config.getPluginName("@scope/pkg@1.0.0")).toBe("@scope/pkg")
expect(Config.getPluginName("@opencode/plugin@2.0.0")).toBe("@opencode/plugin")
})
test("returns full string for package without version", () => {
expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
})
})
describe("deduplicatePlugins", () => {
test("removes duplicates keeping higher priority (later entries)", () => {
const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"]
const result = Config.deduplicatePlugins(plugins)
expect(result).toContain("global-plugin@1.0.0")
expect(result).toContain("local-plugin@2.0.0")
expect(result).toContain("shared-plugin@2.0.0")
expect(result).not.toContain("shared-plugin@1.0.0")
expect(result.length).toBe(3)
})
test("prefers local file over npm package with same name", () => {
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
const result = Config.deduplicatePlugins(plugins)
expect(result.length).toBe(1)
expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
})
test("preserves order of remaining plugins", () => {
const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]
const result = Config.deduplicatePlugins(plugins)
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
})
test("local plugin directory overrides global opencode.json plugin", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
const pluginDir = path.join(opencodeDir, "plugin")
await fs.mkdir(pluginDir, { recursive: true })
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["my-plugin@1.0.0"],
}),
)
await Bun.write(path.join(pluginDir, "my-plugin.js"), "export default {}")
},
})
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const plugins = config.plugin ?? []
const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
expect(myPlugins.length).toBe(1)
expect(myPlugins[0].startsWith("file://")).toBe(true)
},
})
})
})

View File

@@ -25,4 +25,4 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
}

View File

@@ -30,4 +30,4 @@
"publishConfig": {
"directory": "dist"
}
}
}

View File

@@ -77,7 +77,7 @@
[data-slot="user-message-text"] {
white-space: pre-wrap;
word-break: break-all;
word-break: break-word;
overflow: hidden;
background: var(--surface-base);
padding: 8px 12px;
@@ -254,6 +254,7 @@
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
@@ -271,6 +272,7 @@
/* Hide scrollbar */
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
@@ -458,6 +460,7 @@
from {
--border-angle: 0deg;
}
to {
--border-angle: 360deg;
}

View File

@@ -984,6 +984,22 @@ ToolRegistry.register({
ToolRegistry.register({
name: "todowrite",
render(props) {
const todos = createMemo(() => {
const meta = props.metadata?.todos
if (Array.isArray(meta)) return meta
const input = props.input.todos
if (Array.isArray(input)) return input
return []
})
const subtitle = createMemo(() => {
const list = todos()
if (list.length === 0) return ""
return `${list.filter((t: Todo) => t.status === "completed").length}/${list.length}`
})
return (
<BasicTool
{...props}
@@ -991,14 +1007,12 @@ ToolRegistry.register({
icon="checklist"
trigger={{
title: "To-dos",
subtitle: props.input.todos
? `${props.input.todos.filter((t: Todo) => t.status === "completed").length}/${props.input.todos.length}`
: "",
subtitle: subtitle(),
}}
>
<Show when={props.input.todos?.length}>
<Show when={todos().length}>
<div data-component="todos">
<For each={props.input.todos}>
<For each={todos()}>
{(todo: Todo) => (
<Checkbox readOnly checked={todo.status === "completed"}>
<div data-slot="message-part-todo-content" data-completed={todo.status === "completed"}>

View File

@@ -1271,6 +1271,35 @@ SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon
---
### Scaleway
To use [Scaleway Generative APIs](https://www.scaleway.com/en/docs/generative-apis/) with Opencode:
1. Head over to the [Scaleway Console IAM settings](https://console.scaleway.com/iam/api-keys) to generate a new API key.
2. Run the `/connect` command and search for **Scaleway**.
```txt
/connect
```
3. Enter your Scaleway API key.
```txt
┌ API key
└ enter
```
4. Run the `/models` command to select a model like _devstral-2-123b-instruct-2512_ or _gpt-oss-120b_.
```txt
/models
```
---
### Together AI
1. Head over to the [Together AI console](https://api.together.ai), create an account, and click **Add Key**.

View File

@@ -0,0 +1,206 @@
## Payload limits
Prevent blocking storage writes and runaway persisted size
---
### Summary
Large payloads (base64 images, terminal buffers) are currently persisted inside key-value stores:
- web: `localStorage` (sync, blocks the main thread)
- desktop: Tauri Store-backed async storage files (still expensive when values are huge)
Well introduce size-aware persistence policies plus a dedicated “blob store” for large/binary data (IndexedDB on web; separate files on desktop). Prompt/history state will persist only lightweight references to blobs and load them on demand.
---
### Goals
- Stop persisting image `dataUrl` blobs inside web `localStorage`
- Stop persisting image `dataUrl` blobs inside desktop store `.dat` files
- Store image payloads out-of-band (blob store) and load lazily when needed (e.g. when restoring a history item)
- Prevent terminal buffer persistence from exceeding safe size limits
- Keep persistence behavior predictable across web (sync) and desktop (async)
- Provide escape hatches via flags and per-key size caps
---
### Non-goals
- Cross-device sync of images or terminal buffers
- Lossless persistence of full terminal scrollback on web
- Perfect blob deduplication or a complex reference-counting system on day one
---
### Current state
- `packages/app/src/utils/persist.ts` uses `localStorage` (sync) on web and async storage only on desktop.
- Desktop storage is implemented via `@tauri-apps/plugin-store` and writes to named `.dat` files (see `packages/desktop/src/index.tsx`). Large values bloat these files and increase flush costs.
- Prompt history persists under `Persist.global("prompt-history")` (`packages/app/src/components/prompt-input.tsx`) and can include image parts (`dataUrl`).
- Prompt draft persistence uses `packages/app/src/context/prompt.tsx` and can also include image parts (`dataUrl`).
- Terminal buffer is serialized in `packages/app/src/components/terminal.tsx` and persisted in `packages/app/src/context/terminal.tsx`.
---
### Proposed approach
#### 1) Add per-key persistence policies (KV store guardrails)
In `packages/app/src/utils/persist.ts`, add policy hooks for each persisted key:
- `warnBytes` (soft warning threshold)
- `maxBytes` (hard cap)
- `transformIn` / `transformOut` for lossy persistence (e.g. strip or refactor fields)
- `onOversize` strategy: `drop`, `truncate`, or `migrateToBlobRef`
This protects both:
- web (`localStorage` is sync)
- desktop (async, but still expensive to store/flush giant values)
#### 2) Add a dedicated blob store for large data
Introduce a small blob-store abstraction used by the app layer:
- web backend: IndexedDB (store `Blob` values keyed by `id`)
- desktop backend: filesystem directory under the app data directory (store one file per blob)
Store _references_ to blobs inside the persisted JSON instead of the blob contents.
#### 3) Persist image parts as references (not base64 payloads)
Update the prompt image model so the in-memory shape can still use a `dataUrl` for UI, but the persisted representation is reference-based.
Suggested approach:
- Keep `ImageAttachmentPart` with:
- required: `id`, `filename`, `mime`
- optional/ephemeral: `dataUrl?: string`
- new: `blobID?: string` (or `ref: string`)
Persistence rules:
- When writing persisted prompt/history state:
- ensure each image part is stored in blob store (`blobID`)
- persist only metadata + `blobID` (no `dataUrl`)
- When reading persisted prompt/history state:
- do not eagerly load blob payloads
- hydrate `dataUrl` only when needed:
- when applying a history entry into the editor
- before submission (ensure all image parts have usable `dataUrl`)
- when rendering an attachment preview, if required
---
### Phased implementation steps
1. Add guardrails in `persist.ts`
- Implement size estimation in `packages/app/src/utils/persist.ts` using `TextEncoder` byte length on JSON strings.
- Add a policy registry keyed by persist name (e.g. `"prompt-history"`, `"prompt"`, `"terminal"`).
- Add a feature flag (e.g. `persist.payloadLimits`) to enable enforcement gradually.
2. Add blob-store abstraction + platform hooks
- Add a new app-level module (e.g. `packages/app/src/utils/blob.ts`) defining:
- `put(id, bytes|Blob)`
- `get(id)`
- `remove(id)`
- Extend the `Platform` interface (`packages/app/src/context/platform.tsx`) with optional blob methods, or provide a default web implementation and override on desktop:
- web: implement via IndexedDB
- desktop: implement via filesystem files (requires adding a Tauri fs plugin or `invoke` wrappers)
3. Update prompt history + prompt draft persistence to use blob refs
- Update prompt/history serialization paths to ensure image parts are stored as blob refs:
- Prompt history: `packages/app/src/components/prompt-input.tsx`
- Prompt draft: `packages/app/src/context/prompt.tsx`
- Ensure “apply history prompt” hydrates image blobs only when applying the prompt (not during background load).
4. One-time migration for existing persisted base64 images
- On read, detect legacy persisted image parts that include `dataUrl`.
- If a `dataUrl` is found:
- write it into the blob store (convert dataUrl → bytes)
- replace persisted payload with `{ blobID, filename, mime, id }` only
- re-save the reduced version
- If migration fails (missing permissions, quota, etc.), fall back to:
- keep the prompt entry but drop the image payload and mark as unavailable
5. Fix terminal persistence (bounded snapshot)
- In `packages/app/src/context/terminal.tsx`, persist only:
- last `maxLines` and/or
- last `maxBytes` of combined text
- In `packages/app/src/components/terminal.tsx`, keep the full in-memory buffer unchanged.
6. Add basic blob lifecycle cleanup
To avoid “blob directory grows forever”, add one of:
- TTL-based cleanup: store `lastAccessed` per blob and delete blobs older than N days
- Reference scan cleanup: periodically scan prompt-history + prompt drafts, build a set of referenced `blobID`s, and delete unreferenced blobs
Start with TTL-based cleanup (simpler, fewer cross-store dependencies), then consider scan-based cleanup if needed.
---
### Data migration / backward compatibility
- KV store data:
- policies should be tolerant of missing fields (e.g. `dataUrl` missing)
- Image parts:
- treat missing `dataUrl` as “not hydrated yet”
- treat missing `blobID` (legacy) as “not persisted” or “needs migration”
- Desktop:
- blob files should be namespaced (e.g. `opencode/blobs/<blobID>`) to avoid collisions
---
### Risk + mitigations
- Risk: blob store is unavailable (IndexedDB disabled, desktop fs permissions).
- Mitigation: keep base state functional; persist prompts without image payloads and show a clear placeholder.
- Risk: lazy hydration introduces edge cases when submitting.
- Mitigation: add a pre-submit “ensure images hydrated” step; if hydration fails, block submission with a clear error or submit without images.
- Risk: dataUrl→bytes conversion cost during migration.
- Mitigation: migrate incrementally (only when reading an entry) and/or use `requestIdleCallback` on web.
- Risk: blob cleanup deletes blobs still needed.
- Mitigation: TTL default should be conservative; scan-based cleanup should only delete blobs unreferenced by current persisted state.
---
### Validation plan
- Unit-level:
- size estimation + policy enforcement in `persist.ts`
- blob store put/get/remove round trips (web + desktop backends)
- Manual scenarios:
- attach multiple images, reload, and confirm:
- KV store files do not balloon
- images can be restored when selecting history items
- open terminal with large output and confirm reload restores bounded snapshot quickly
- confirm prompt draft persistence still works in `packages/app/src/context/prompt.tsx`
---
### Rollout plan
- Phase 1: ship with `persist.payloadLimits` off; log oversize detections in dev.
- Phase 2: enable image blob refs behind `persist.imageBlobs` (web + desktop).
- Phase 3: enable terminal truncation and enforce hard caps for known hot keys.
- Phase 4: enable blob cleanup behind `persist.blobGc` (TTL first).
- Provide quick kill switches by disabling each flag independently.
---
### Open questions
- What should the canonical persisted image schema be (`blobID` field name, placeholder shape, etc.)?
- Desktop implementation detail:
- add `@tauri-apps/plugin-fs` vs custom `invoke()` commands for blob read/write?
- where should blob files live (appDataDir) and what retention policy is acceptable?
- Web implementation detail:
- do we store `Blob` directly in IndexedDB, or store base64 strings?
- Should prompt-history images be retained indefinitely, or only for the last `MAX_HISTORY` entries?

141
specs/02-cache-eviction.md Normal file
View File

@@ -0,0 +1,141 @@
## Cache eviction
Add explicit bounds for long-lived in-memory state
---
### Summary
Several in-memory caches grow without limits during long sessions. Well introduce explicit eviction (LRU + TTL + size caps) for sessions/messages/file contents and global per-directory sync stores.
---
### Goals
- Prevent unbounded memory growth from caches that survive navigation
- Add consistent eviction primitives shared across contexts
- Keep UI responsive under heavy usage (many sessions, large files)
---
### Non-goals
- Perfect cache hit rates or prefetch strategies
- Changing server APIs or adding background jobs
- Persisting caches for offline use
---
### Current state
- Global sync uses per-directory child stores without eviction in `packages/app/src/context/global-sync.tsx`.
- File contents cached in `packages/app/src/context/file.tsx` with no cap.
- Session-heavy pages include `packages/app/src/pages/session.tsx` and `packages/app/src/pages/layout.tsx`.
---
### Proposed approach
- Introduce a shared cache utility that supports:
- `maxEntries`, `maxBytes` (approx), and `ttlMs`
- LRU ordering with explicit `touch(key)` on access
- deterministic `evict()` and `clear()` APIs
- Apply the utility to:
- global-sync per-directory child stores (cap number of directories kept “hot”)
- file contents cache (cap by entries + bytes, with TTL)
- session/message caches (cap by session count, and optionally message count)
- Add feature flags per cache domain to allow partial rollout (e.g. `cache.eviction.files`).
---
### Phased implementation steps
1. Add a generic cache helper
- Create `packages/app/src/utils/cache.ts` with a small, dependency-free LRU+TTL.
- Keep it framework-agnostic and usable from Solid contexts.
Sketch:
```ts
type CacheOpts = {
maxEntries: number
ttlMs?: number
maxBytes?: number
sizeOf?: (value: unknown) => number
}
function createLruCache<T>(opts: CacheOpts) {
// get, set, delete, clear, evictExpired, stats
}
```
2. Apply eviction to file contents
- In `packages/app/src/context/file.tsx`:
- wrap the existing file-content map in the LRU helper
- approximate size via `TextEncoder` length of content strings
- evict on `set` and periodically via `requestIdleCallback` when available
- Add a small TTL (e.g. 1030 minutes) to discard stale contents.
3. Apply eviction to global-sync child stores
- In `packages/app/src/context/global-sync.tsx`:
- track child stores by directory key in an LRU with `maxEntries`
- call a `dispose()` hook on eviction to release subscriptions and listeners
- Ensure “currently active directory” is always `touch()`d to avoid surprise evictions.
4. Apply eviction to session/message caches
- Identify the session/message caching touchpoints used by `packages/app/src/pages/session.tsx`.
- Add caps that reflect UI needs (e.g. last 1020 sessions kept, last N messages per session if cached).
5. Add developer tooling
- Add a debug-only stats readout (console or dev panel) for cache sizes and eviction counts.
- Add a one-click “clear caches” action for troubleshooting.
---
### Data migration / backward compatibility
- No persisted schema changes are required since this targets in-memory caches.
- If any cache is currently mirrored into persistence, keep keys stable and only change in-memory retention.
---
### Risk + mitigations
- Risk: evicting content still needed causes extra refetches and flicker.
- Mitigation: always pin “active” entities and evict least-recently-used first.
- Risk: disposing global-sync child stores could leak listeners if not cleaned up correctly.
- Mitigation: require an explicit `dispose()` contract and add dev assertions for listener counts.
- Risk: approximate byte sizing is imprecise.
- Mitigation: combine entry caps with byte caps and keep thresholds conservative.
---
### Validation plan
- Add tests for `createLruCache` covering TTL expiry, LRU ordering, and eviction triggers.
- Manual scenarios:
- open many files and confirm memory stabilizes and UI remains responsive
- switch across many directories and confirm global-sync does not continuously grow
- long session navigation loop and confirm caches plateau
---
### Rollout plan
- Land cache utility first with flags default off.
- Enable file cache eviction first (lowest behavioral risk).
- Enable global-sync eviction next with conservative caps and strong logging in dev.
- Enable session/message eviction last after observing real usage patterns.
---
### Open questions
- What are the current session/message cache structures and their ownership boundaries?
- Which child stores in `global-sync.tsx` have resources that must be disposed explicitly?
- What caps are acceptable for typical workflows (files open, directories visited, sessions viewed)?

View File

@@ -0,0 +1,145 @@
## Request throttling
Debounce and cancel high-frequency server calls
---
### Summary
Some user interactions trigger bursts of server requests that can overlap and return out of order. Well debounce frequent triggers and cancel in-flight requests (or ignore stale results) for file search and LSP refresh.
---
### Goals
- Reduce redundant calls from file search and LSP refresh
- Prevent stale responses from overwriting newer UI state
- Preserve responsive typing and scrolling during high activity
---
### Non-goals
- Changing server-side behavior or adding new endpoints
- Implementing global request queues for all SDK calls
- Persisting search results across reloads
---
### Current state
- File search calls `sdk.client.find.files` via `files.searchFilesAndDirectories`.
- LSP refresh is triggered frequently (exact call sites vary, but the refresh behavior is high-frequency).
- Large UI modules involved include `packages/app/src/pages/layout.tsx` and `packages/app/src/components/prompt-input.tsx`.
---
### Proposed approach
- Add a small request coordinator utility:
- debounced triggering (leading/trailing configurable)
- cancellation via `AbortController` when supported
- stale-result protection via monotonic request ids when abort is not supported
- Integrate coordinator into:
- `files.searchFilesAndDirectories` (wrap `sdk.client.find.files`)
- LSP refresh call path (wrap refresh invocation and ensure only latest applies)
---
### Phased implementation steps
1. Add a debounced + cancellable helper
- Create `packages/app/src/utils/requests.ts` with:
- `createDebouncedAsync(fn, delayMs)`
- `createLatestOnlyAsync(fn)` that drops stale responses
- Prefer explicit, readable primitives over a single complex abstraction.
Sketch:
```ts
function createLatestOnlyAsync<TArgs extends unknown[], TResult>(
fn: (args: { input: TArgs; signal?: AbortSignal }) => Promise<TResult>,
) {
let id = 0
let controller: AbortController | undefined
return async (...input: TArgs) => {
id += 1
const current = id
controller?.abort()
controller = new AbortController()
const result = await fn({ input, signal: controller.signal })
if (current !== id) return
return result
}
}
```
2. Apply to file search
- Update `files.searchFilesAndDirectories` to:
- debounce input changes (e.g. 150300 ms)
- abort prior request when a new query begins
- ignore results if they are stale
- Ensure “empty query” is handled locally without calling the server.
3. Apply to LSP refresh
- Identify the refresh trigger points used during typing and file switching.
- Add:
- debounce for rapid triggers (e.g. 250500 ms)
- cancellation for in-flight refresh if supported
- last-write-wins behavior for applying diagnostics/results
4. Add feature flags and metrics
- Add flags:
- `requests.debounce.fileSearch`
- `requests.latestOnly.lspRefresh`
- Add simple dev-only counters for “requests started / aborted / applied”.
---
### Data migration / backward compatibility
- No persisted data changes.
- Behavior is compatible as long as UI state updates only when the “latest” request resolves.
---
### Risk + mitigations
- Risk: aggressive debounce makes UI feel laggy.
- Mitigation: keep delays small and tune separately for search vs refresh.
- Risk: aborting requests may surface as errors in logs.
- Mitigation: treat `AbortError` as expected and do not log it as a failure.
- Risk: SDK method may not accept `AbortSignal`.
- Mitigation: use request-id stale protection even without true cancellation.
---
### Validation plan
- Manual scenarios:
- type quickly in file search and confirm requests collapse and results stay correct
- trigger LSP refresh repeatedly and confirm diagnostics do not flicker backward
- Add a small unit test for latest-only behavior (stale results are ignored).
---
### Rollout plan
- Ship helpers behind flags default off.
- Enable file search debounce first (high impact, easy to validate).
- Enable LSP latest-only next, then add cancellation if SDK supports signals.
- Keep a quick rollback by disabling the flags.
---
### Open questions
- Does `sdk.client.find.files` accept an abort signal today, or do we need stale-result protection only?
- Where is LSP refresh initiated, and does it have a single chokepoint we can wrap?
- What debounce values feel best for common repos and slower machines?

View File

@@ -0,0 +1,125 @@
## Spy acceleration
Replace O(N) DOM scans in session view
---
### Summary
The session scroll-spy currently scans the DOM with `querySelectorAll` and walks message nodes, which becomes expensive as message count grows. Well replace the scan with an observer-based or indexed approach that scales smoothly.
---
### Goals
- Remove repeated full DOM scans during scroll in the session view
- Keep “current message” tracking accurate during streaming and layout shifts
- Provide a safe fallback path for older browsers and edge cases
---
### Non-goals
- Visual redesign of the session page
- Changing message rendering structure or IDs
- Perfect accuracy during extreme layout thrash
---
### Current state
- `packages/app/src/pages/session.tsx` uses `querySelectorAll('[data-message-id]')` for scroll-spy.
- The page is large and handles many responsibilities, increasing the chance of perf regressions.
---
### Proposed approach
Implement a two-tier scroll-spy:
- Primary: `IntersectionObserver` to track which message elements are visible, updated incrementally.
- Secondary: binary search over precomputed offsets when observer is unavailable or insufficient.
- Use `ResizeObserver` (and a lightweight “dirty” flag) to refresh offsets only when layout changes.
---
### Phased implementation steps
1. Extract a dedicated scroll-spy module
- Create `packages/app/src/pages/session/scroll-spy.ts` (or similar) that exposes:
- `register(el, id)` and `unregister(id)`
- `getActiveId()` signal/store
- Keep DOM operations centralized and easy to profile.
2. Add IntersectionObserver tracking
- Observe each `[data-message-id]` element once, on mount.
- Maintain a small map of `id -> intersectionRatio` (or visible boolean).
- Pick the active id by:
- highest intersection ratio, then
- nearest to top of viewport as a tiebreaker
3. Add binary search fallback
- Maintain an ordered list of `{ id, top }` positions.
- On scroll (throttled via `requestAnimationFrame`), compute target Y and binary search to find nearest message.
- Refresh the positions list on:
- message list mutations (new messages)
- container resize events (ResizeObserver)
- explicit “layout changed” events after streaming completes
4. Remove `querySelectorAll` hot path
- Keep a one-time initial query only as a bridge during rollout, then remove it.
- Ensure newly rendered messages are registered via refs rather than scanning the whole DOM.
5. Add a feature flag and fallback
- Add `session.scrollSpyOptimized` flag.
- If observer setup fails, fall back to the existing scan behavior temporarily.
---
### Data migration / backward compatibility
- No persisted data changes.
- IDs remain sourced from existing `data-message-id` attributes.
---
### Risk + mitigations
- Risk: observer ordering differs from previous “active message” logic.
- Mitigation: keep selection rules simple, document them, and add a small tolerance for tie cases.
- Risk: layout shifts cause incorrect offset indexing.
- Mitigation: refresh offsets with ResizeObserver and after message streaming batches.
- Risk: performance regressions from observing too many nodes.
- Mitigation: prefer one observer instance and avoid per-node observers.
---
### Validation plan
- Manual scenarios:
- very long sessions (hundreds of messages) and continuous scrolling
- streaming responses that append content and change heights
- resizing the window and toggling side panels
- Add a dev-only profiler hook to log time spent in scroll-spy updates per second.
---
### Rollout plan
- Land extracted module first, still using the old scan internally.
- Add observer implementation behind `session.scrollSpyOptimized` off by default.
- Enable flag for internal testing, then default on after stability.
- Keep fallback code for one release cycle, then remove scan path.
---
### Open questions
- What is the exact definition of “active” used elsewhere (URL hash, sidebar highlight, breadcrumb)?
- Are messages virtualized today, or are all DOM nodes mounted at once?
- Which container is the scroll root (window vs an inner div), and does it change by layout mode?

View File

@@ -0,0 +1,153 @@
## Component modularity
Split mega-components and dedupe scoped caches
---
### Summary
Several large UI files combine rendering, state, persistence, and caching patterns, including repeated “scoped session cache” infrastructure. Well extract reusable primitives and break large components into smaller units without changing user-facing behavior.
---
### Goals
- Reduce complexity in:
- `packages/app/src/pages/session.tsx`
- `packages/app/src/pages/layout.tsx`
- `packages/app/src/components/prompt-input.tsx`
- Deduplicate “scoped session cache” logic into a shared utility
- Make performance fixes (eviction, throttling) easier to implement safely
---
### Non-goals
- Large redesign of routing or page structure
- Moving to a different state management approach
- Rewriting all contexts in one pass
---
### Current state
- Session page is large and mixes concerns (`packages/app/src/pages/session.tsx`).
- Layout is also large and likely coordinates multiple global concerns (`packages/app/src/pages/layout.tsx`).
- Prompt input is large and includes persistence and interaction logic (`packages/app/src/components/prompt-input.tsx`).
- Similar “scoped cache” patterns appear in multiple places (session-bound maps, per-session stores, ad hoc memoization).
---
### Proposed approach
- Introduce a shared “scoped store” utility to standardize session-bound caches:
- keyed by `sessionId`
- automatic cleanup via TTL or explicit `dispose(sessionId)`
- optional LRU cap for many sessions
- Break mega-components into focused modules with clear boundaries:
- “view” components (pure rendering)
- “controller” hooks (state + effects)
- “services” (SDK calls, persistence adapters)
---
### Phased implementation steps
1. Inventory and name the repeated pattern
- Identify the repeated “scoped session cache” usage sites in:
- `packages/app/src/pages/session.tsx`
- `packages/app/src/pages/layout.tsx`
- `packages/app/src/components/prompt-input.tsx`
- Write down the common operations (get-or-create, clear-on-session-change, dispose).
2. Add a shared scoped-cache utility
- Create `packages/app/src/utils/scoped-cache.ts`:
- `createScopedCache(createValue, opts)` returning `get(key)`, `peek(key)`, `delete(key)`, `clear()`
- optional TTL + LRU caps to avoid leak-by-design
- Keep the API tiny and explicit so call sites stay readable.
Sketch:
```ts
type ScopedOpts = { maxEntries?: number; ttlMs?: number }
function createScopedCache<T>(createValue: (key: string) => T, opts: ScopedOpts) {
// store + eviction + dispose hooks
}
```
3. Extract session page submodules
- Split `packages/app/src/pages/session.tsx` into:
- `session/view.tsx` for rendering layout
- `session/messages.tsx` for message list
- `session/composer.tsx` for input wiring
- `session/scroll-spy.ts` for active message tracking
- Keep exports stable so routing code changes minimally.
4. Extract layout coordination logic
- Split `packages/app/src/pages/layout.tsx` into:
- shell layout view
- navigation/controller logic
- global keyboard shortcuts (if present)
- Ensure each extracted piece has a narrow prop surface and no hidden globals.
5. Extract prompt-input state machine
- Split `packages/app/src/components/prompt-input.tsx` into:
- `usePromptComposer()` hook (draft, submission, attachments)
- presentational input component
- Route persistence through existing `packages/app/src/context/prompt.tsx`, but isolate wiring code.
6. Replace ad hoc scoped caches with the shared utility
- Swap one call site at a time and keep behavior identical.
- Add a flag `scopedCache.shared` to fall back to the old implementation if needed.
---
### Data migration / backward compatibility
- No persisted schema changes are required by modularization alone.
- If any cache keys change due to refactors, keep a compatibility reader for one release cycle.
---
### Risk + mitigations
- Risk: refactors cause subtle behavior changes (focus, keyboard shortcuts, scroll position).
- Mitigation: extract without logic changes first, then improve behavior in later diffs.
- Risk: new shared cache introduces lifecycle bugs.
- Mitigation: require explicit cleanup hooks and add dev assertions for retained keys.
- Risk: increased file count makes navigation harder temporarily.
- Mitigation: use consistent naming and keep the folder structure shallow.
---
### Validation plan
- Manual regression checklist:
- compose, attach images, submit, and reload draft
- navigate between sessions and confirm caches dont bleed across IDs
- verify terminal, file search, and scroll-spy still behave normally
- Add lightweight unit tests for `createScopedCache` eviction and disposal behavior.
---
### Rollout plan
- Phase 1: introduce `createScopedCache` unused, then adopt in one low-risk area.
- Phase 2: extract session submodules with no behavior changes.
- Phase 3: flip remaining scoped caches to shared utility behind `scopedCache.shared`.
- Phase 4: remove old duplicated implementations after confidence.
---
### Open questions
- Where exactly is “scoped session cache” duplicated today, and what are the differing lifecycle rules?
- Which extracted modules must remain synchronous for Solid reactivity to behave correctly?
- Are there implicit dependencies in the large files (module-level state) that need special handling?

196
specs/perf-roadmap.md Normal file
View File

@@ -0,0 +1,196 @@
## Performance roadmap
Sequenced delivery plan for app scalability + maintainability
---
### Objective
Deliver the top 5 app improvements (performance + long-term flexibility) in a safe, incremental sequence that:
- minimizes regression risk
- keeps changes reviewable (small PRs)
- provides escape hatches (flags / caps)
- validates improvements with targeted measurements
This roadmap ties together:
- `specs/01-persist-payload-limits.md`
- `specs/02-cache-eviction.md`
- `specs/03-request-throttling.md`
- `specs/04-scroll-spy-optimization.md`
- `specs/05-modularize-and-dedupe.md`
---
### Guiding principles
- Prefer “guardrails first”: add caps/limits and do no harm, then optimize.
- Always ship behind flags if behavior changes (especially persistence and eviction).
- Optimize at chokepoints (SDK call wrappers, storage wrappers, scroll-spy module) instead of fixing symptoms at every call site.
- Make “hot paths” explicitly measurable in dev (e.g. via `packages/app/src/utils/perf.ts`).
---
### Phase 0 — Baseline + flags (prep)
**Goal:** make later changes safe to land and easy to revert.
**Deliverables**
- Feature-flag plumbing for:
- persistence payload limits (`persist.payloadLimits`)
- request debouncing/latest-only (`requests.*`)
- cache eviction (`cache.eviction.*`)
- optimized scroll spy (`session.scrollSpyOptimized`)
- shared scoped cache (`scopedCache.shared`)
- Dev-only counters/logs for:
- persist oversize detections
- request aborts/stale drops
- eviction counts and retained sizes
- scroll-spy compute time per second
**Exit criteria**
- Flags exist but default “off” for behavior changes.
- No user-visible behavior changes.
**Effort / risk**: `SM` / low
---
### Phase 1 — Stop the worst “jank generators” (storage + request storms)
**Goal:** remove the highest-frequency sources of main-thread blocking and redundant work.
**Work items**
- Implement file search debounce + stale-result protection
- Spec: `specs/03-request-throttling.md`
- Start with file search only (lowest risk, easy to observe).
- Add persistence payload size checks + warnings (no enforcement yet)
- Spec: `specs/01-persist-payload-limits.md`
- Focus on detecting oversized keys and preventing repeated write attempts.
- Ship prompt-history “strip image dataUrl” behind a flag
- Spec: `specs/01-persist-payload-limits.md`
- Keep image metadata placeholders so UI remains coherent.
**Exit criteria**
- Fast typing in file search generates at most 1 request per debounce window.
- Oversize persisted keys are detected and do not cause repeated blocking writes.
- Prompt history reload does not attempt to restore base64 `dataUrl` on web when flag enabled.
**Effort / risk**: `M` / lowmed
---
### Phase 2 — Bound memory growth (in-memory eviction)
**Goal:** stabilize memory footprint for long-running sessions and “project hopping”.
**Work items**
- Introduce shared LRU/TTL cache helper
- Spec: `specs/02-cache-eviction.md`
- Apply eviction to file contents cache first
- Spec: `specs/02-cache-eviction.md`
- Pin open tabs / active file to prevent flicker.
- Add conservative eviction for global-sync per-directory child stores
- Spec: `specs/02-cache-eviction.md`
- Ensure evicted children are fully disposed.
- (Optional) session/message eviction if memory growth persists after the above
- Spec: `specs/02-cache-eviction.md`
**Exit criteria**
- Opening many files does not continuously increase JS heap without bound.
- Switching across many directories does not keep all directory stores alive indefinitely.
- Eviction never removes currently active session/file content.
**Effort / risk**: `ML` / med
---
### Phase 3 — Large session scroll scalability (scroll spy)
**Goal:** keep scrolling smooth as message count increases.
**Work items**
- Extract scroll-spy logic into a dedicated module (no behavior change)
- Spec: `specs/04-scroll-spy-optimization.md`
- Implement IntersectionObserver tracking behind flag
- Spec: `specs/04-scroll-spy-optimization.md`
- Add binary search fallback for non-observer environments
- Spec: `specs/04-scroll-spy-optimization.md`
**Exit criteria**
- Scroll handler no longer calls `querySelectorAll('[data-message-id]')` on every scroll tick.
- Long sessions (hundreds of messages) maintain smooth scrolling.
- Active message selection remains stable during streaming/layout shifts.
**Effort / risk**: `M` / med
---
### Phase 4 — “Make it easy to keep fast” (modularity + dedupe)
**Goal:** reduce maintenance cost and make future perf work cheaper.
**Work items**
- Introduce shared scoped-cache utility and adopt in one low-risk area
- Spec: `specs/05-modularize-and-dedupe.md`
- Incrementally split mega-components (one PR per extraction)
- Spec: `specs/05-modularize-and-dedupe.md`
- Prioritize extracting:
- session scroll/backfill logic
- prompt editor model/history
- layout event/shortcut wiring
- Remove duplicated patterns after confidence + one release cycle
**Exit criteria**
- Each mega-file drops below a target size (suggestion):
- `session.tsx` < ~800 LOC
- `prompt-input.tsx` < ~900 LOC
- “Scoped cache” has a single implementation used across contexts.
- Future perf fixes land in isolated modules with minimal cross-cutting change.
**Effort / risk**: `L` / medhigh
---
### Recommended PR slicing (keeps reviews safe)
- PR A: add request helpers + file search debounce (flagged)
- PR B: persist size detection + logs (no behavior change)
- PR C: prompt history strip images (flagged)
- PR D: cache helper + file content eviction (flagged)
- PR E: global-sync child eviction (flagged)
- PR F: scroll-spy extraction (no behavior change)
- PR G: optimized scroll-spy implementation (flagged)
- PR H+: modularization PRs (small, mechanical refactors)
---
### Rollout strategy
- Keep defaults conservative and ship flags “off” first.
- Enable flags internally (dev builds) to gather confidence.
- Flip defaults in this order:
1. file search debounce
2. prompt-history image stripping
3. file-content eviction
4. global-sync child eviction
5. optimized scroll-spy
---
### Open questions
- What are acceptable defaults for storage caps and cache sizes for typical OpenCode usage?
- Does the SDK support `AbortSignal` end-to-end for cancellation, or do we rely on stale-result dropping?
- Should web and desktop persistence semantics be aligned (even if desktop has async storage available)?