Compare commits

...
33 Commits
Author SHA1 Message Date
David Hill 5c2960a0d8 more concepts 2026-03-26 12:24:19 +00:00
David Hill 431aca1df9 unsure 2026-03-25 14:19:03 +00:00
David Hill c25077c2e5 feat(app): add session spinner concept lab
Let the desktop app preview and tune alternate loading treatments for session titles so spinner ideas can be explored in context before replacing the default UI.
2026-03-25 14:18:29 +00:00
Adam 4167e25c7e fix(app): opencode web server url 2026-03-25 06:41:00 -05:00
Adam 1041ae91d1 Reapply "fix(app): startup efficiency"
This reverts commit 898456a25c.
2026-03-25 06:25:57 -05:00
Adam 898456a25c Revert "fix(app): startup efficiency" 2026-03-25 06:25:05 -05:00
Adam 53d0b58ebf fix(app): hash inline script for csp 2026-03-25 05:59:06 -05:00
Adam 2b0baf97bd Reapply "fix(app): more startup efficiency (#18985)"
This reverts commit cbe1337f24.
2026-03-25 05:59:06 -05:00
Adam 0dbfefa080 Reapply "fix(app): startup efficiency (#18854)"
This reverts commit a379eb3867.
2026-03-25 05:59:05 -05:00
Shoubhit Dash d1c49ba210 fix(app): move message navigation off cmd+arrow (#18728) 2026-03-25 05:24:55 -05:00
Brendan Allan 3ea72aec21 app: pre-warm project globalSync state when navigate project via keybind (#19088) 2026-03-25 17:32:49 +08:00
Brendan Allan 9717383823 electron: remove file extension from electron-store wrapper (#19082) 2026-03-25 07:57:27 +00:00
Brendan Allan 5d9e780029 electron: add createDirectory to open directory picker (#19071) 2026-03-25 06:25:51 +00:00
Luke Parker aa11fa865d fix: unblock beta conflict recovery (#19068) 2026-03-25 06:14:38 +00:00
Luke Parker 9a64bdb539 fix: beta resolver typecheck + build smoke check (#19060) 2026-03-25 05:45:30 +00:00
Aiden Cline 71693cc24b tweak: only spawn lsp servers for files in current instance (or cwd if instance is global) (#19058) 2026-03-25 00:31:29 -05:00
Luke Parker 700f57112a fix: provide merge context to beta conflict resolver (#19055) 2026-03-25 04:45:37 +00:00
Dax 0a80ef4278 fix(opencode): avoid snapshotting files over 2MB (#19043) 2026-03-25 04:43:48 +00:00
Dax Raad 4f9667c4bb Change issue close reason from not_planned to completed 2026-03-24 23:55:10 -04:00
Dax Raad be142b00bd Process issues sequentially to avoid rate limits 2026-03-24 23:54:27 -04:00
Dax Raad 45c2573979 Fix close-issues workflow permissions
- Add contents: read permission for checkout
- Use github.token instead of secrets.GITHUB_TOKEN
2026-03-24 23:51:46 -04:00
Dax Raad 79e9d19019 Add close-issues script and GitHub Action
- Create script/github/close-issues.ts to close stale issues after 60 days
- Add GitHub Action workflow to run daily at 2 AM
- Remove old stale-issues workflow to avoid conflicts
2026-03-24 23:50:35 -04:00
Dax Raad 958a80cc05 fix: increase operations-per-run to 1000 and pin stale action to v10.2.0
The stale-issues workflow was hitting the default 30 operations limit,
preventing it from processing all 2900+ issues/PRs. Increased to 1000
to handle the full backlog. Also pinned to exact v10.2.0 for reproducibility.
2026-03-24 23:38:15 -04:00
Kit Langton 4647aa80ac effectify Worktree service (#18679) 2026-03-24 20:26:21 -04:00
Adam a379eb3867 Revert "fix(app): startup efficiency (#18854)"
This reverts commit 546748a461.
2026-03-24 18:36:37 -05:00
Adam cbe1337f24 Revert "fix(app): more startup efficiency (#18985)"
This reverts commit 98b3340cee.
2026-03-24 18:36:25 -05:00
Kit Langton 50f6aa3763 fix(opencode): skip typechecking generated models snapshot (#19018) 2026-03-24 19:11:45 -04:00
opencode 0dcdf5f529 release: v1.3.2 2026-03-24 22:50:35 +00:00
Dax Raad 4586b41ffd change model for changelog 2026-03-24 18:25:52 -04:00
Dax Raad 35884defd8 ci 2026-03-24 18:24:34 -04:00
Dax 15dc33d1a3 feat(tui): add heap snapshot functionality for TUI and server (#19028) 2026-03-24 18:20:11 -04:00
opencode-agent[bot] 1398674e53 chore: update nix node_modules hashes 2026-03-24 22:07:32 +00:00
Jay V afc4c831eb tweak: use theme tokens for debug bar surface 2026-03-24 22:07:32 +00:00
56 changed files with 2904 additions and 698 deletions
+24
View File
@@ -0,0 +1,24 @@
name: close-issues
on:
schedule:
- cron: "0 2 * * *" # Daily at 2:00 AM
workflow_dispatch:
jobs:
close:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Close stale issues
env:
GITHUB_TOKEN: ${{ github.token }}
run: bun script/github/close-issues.ts
-33
View File
@@ -1,33 +0,0 @@
name: stale-issues
on:
schedule:
- cron: "30 1 * * *" # Daily at 1:30 AM
workflow_dispatch:
env:
DAYS_BEFORE_STALE: 90
DAYS_BEFORE_CLOSE: 7
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v10
with:
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
stale-issue-label: "stale"
close-issue-message: |
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
Feel free to reopen if you still need this!
stale-issue-message: |
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
remove-stale-when-updated: true
exempt-issue-labels: "pinned,security,feature-request,on-hold"
start-date: "2025-12-27"
+19 -3
View File
@@ -1,5 +1,21 @@
---
model: opencode/kimi-k2.5
---
create UPCOMING_CHANGELOG.md
it should have sections
```
# TUI
# Desktop
# Core
# Misc
```
go through each PR merged since the last tag
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
+16 -16
View File
@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -79,7 +79,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -113,7 +113,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -140,7 +140,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -164,7 +164,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -188,7 +188,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -221,7 +221,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -252,7 +252,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -281,7 +281,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -297,7 +297,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.3.1",
"version": "1.3.2",
"bin": {
"opencode": "./bin/opencode",
},
@@ -422,7 +422,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -446,7 +446,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.3.1",
"version": "1.3.2",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -457,7 +457,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -492,7 +492,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -538,7 +538,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"zod": "catalog:",
},
@@ -549,7 +549,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-SXuq4e2Nl7qpTeTo9aOJqVhYdDdUPHAGUbRvKGN7bjo=",
"aarch64-linux": "sha256-2i+Uhz4GjL/eHd2L9/N/igEeY4wwhtZ9qwR9xXnQKyc=",
"aarch64-darwin": "sha256-s+v8PPzZYCm5Oa0pgUmBuGK9B3D5rFMcwLd9s1nBnkg=",
"x86_64-darwin": "sha256-YDAY3KM1d01Yh8/YqVRZC1dMKBDegiPtUCPg3rsZ1oo="
"x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=",
"aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=",
"aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=",
"x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.1",
"version": "1.3.2",
"description": "",
"type": "module",
"exports": {
+2 -6
View File
@@ -55,7 +55,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
<Tooltip value={props.tip} placement="top">
<div
classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide,
}}
>
@@ -363,11 +363,7 @@ export function DebugBar() {
return (
<aside
aria-label={language.t("debugBar.ariaLabel")}
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
style={{
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
"border-color": "color-mix(in srgb, white 14%, transparent)",
}}
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
>
<div class="grid grid-cols-5 gap-px font-mono">
<Cell
+199
View File
@@ -0,0 +1,199 @@
import type { ComponentProps } from "solid-js"
import { createEffect, createSignal, onCleanup } from "solid-js"
type Kind = "pendulum" | "compress" | "sort"
export type BrailleKind = Kind
const bits = [
[0x01, 0x08],
[0x02, 0x10],
[0x04, 0x20],
[0x40, 0x80],
]
const seeded = (seed: number) => {
let s = seed
return () => {
s = (s * 1664525 + 1013904223) & 0xffffffff
return (s >>> 0) / 0xffffffff
}
}
const pendulum = (cols: number, max = 1) => {
const total = 120
const span = cols * 2
const frames = [] as string[]
for (let t = 0; t < total; t++) {
const codes = Array.from({ length: cols }, () => 0x2800)
const p = t / total
const spread = Math.sin(Math.PI * p) * max
const phase = p * Math.PI * 8
for (let pc = 0; pc < span; pc++) {
const swing = Math.sin(phase + pc * spread)
const center = (1 - swing) * 1.5
for (let row = 0; row < 4; row++) {
if (Math.abs(row - center) >= 0.7) continue
codes[Math.floor(pc / 2)] |= bits[row][pc % 2]
}
}
frames.push(codes.map((code) => String.fromCharCode(code)).join(""))
}
return frames
}
const compress = (cols: number) => {
const total = 100
const span = cols * 2
const dots = span * 4
const frames = [] as string[]
const rand = seeded(42)
const weight = Array.from({ length: dots }, () => rand())
for (let t = 0; t < total; t++) {
const codes = Array.from({ length: cols }, () => 0x2800)
const p = t / total
const sieve = Math.max(0.1, 1 - p * 1.2)
const squeeze = Math.min(1, p / 0.85)
const active = Math.max(1, span * (1 - squeeze * 0.95))
for (let pc = 0; pc < span; pc++) {
const map = (pc / span) * active
if (map >= active) continue
const next = Math.round(map)
if (next >= span) continue
const char = Math.floor(next / 2)
const dot = next % 2
for (let row = 0; row < 4; row++) {
if (weight[pc * 4 + row] >= sieve) continue
codes[char] |= bits[row][dot]
}
}
frames.push(codes.map((code) => String.fromCharCode(code)).join(""))
}
return frames
}
const sort = (cols: number) => {
const span = cols * 2
const total = 100
const frames = [] as string[]
const rand = seeded(19)
const start = Array.from({ length: span }, () => rand() * 3)
const end = Array.from({ length: span }, (_, i) => (i / Math.max(1, span - 1)) * 3)
for (let t = 0; t < total; t++) {
const codes = Array.from({ length: cols }, () => 0x2800)
const p = t / total
const cursor = p * span * 1.2
for (let pc = 0; pc < span; pc++) {
const char = Math.floor(pc / 2)
const dot = pc % 2
const delta = pc - cursor
let center
if (delta < -3) {
center = end[pc]
} else if (delta < 2) {
const blend = 1 - (delta + 3) / 5
const ease = blend * blend * (3 - 2 * blend)
center = start[pc] + (end[pc] - start[pc]) * ease
if (Math.abs(delta) < 0.8) {
for (let row = 0; row < 4; row++) codes[char] |= bits[row][dot]
continue
}
} else {
center = start[pc] + Math.sin(p * Math.PI * 16 + pc * 2.7) * 0.6 + Math.sin(p * Math.PI * 9 + pc * 1.3) * 0.4
}
center = Math.max(0, Math.min(3, center))
for (let row = 0; row < 4; row++) {
if (Math.abs(row - center) >= 0.7) continue
codes[char] |= bits[row][dot]
}
}
frames.push(codes.map((code) => String.fromCharCode(code)).join(""))
}
return frames
}
const build = (kind: Kind, cols: number) => {
if (kind === "compress") return compress(cols)
if (kind === "sort") return sort(cols)
return pendulum(cols)
}
const pace = (kind: Kind) => {
if (kind === "pendulum") return 16
return 40
}
const cache = new Map<string, string[]>()
const get = (kind: Kind, cols: number) => {
const key = `${kind}:${cols}`
const saved = cache.get(key)
if (saved) return saved
const made = build(kind, cols)
cache.set(key, made)
return made
}
export const getBrailleFrames = (kind: Kind, cols: number) => get(kind, cols)
export function Braille(props: {
kind?: Kind
cols?: number
rate?: number
class?: string
classList?: ComponentProps<"span">["classList"]
style?: ComponentProps<"span">["style"]
label?: string
}) {
const kind = () => props.kind ?? "pendulum"
const cols = () => props.cols ?? 2
const rate = () => props.rate ?? 1
const [idx, setIdx] = createSignal(0)
createEffect(() => {
if (typeof window === "undefined") return
const frames = get(kind(), cols())
setIdx(0)
const id = window.setInterval(
() => {
setIdx((idx) => (idx + 1) % frames.length)
},
Math.max(10, Math.round(pace(kind()) / rate())),
)
onCleanup(() => window.clearInterval(id))
})
return (
<span
role="status"
aria-label={props.label ?? "Loading"}
class={props.class}
classList={props.classList}
style={props.style}
>
<span aria-hidden="true">{get(kind(), cols())[idx()]}</span>
</span>
)
}
export function Pendulum(props: Omit<Parameters<typeof Braille>[0], "kind">) {
return <Braille {...props} kind="pendulum" />
}
@@ -32,6 +32,25 @@ describe("command keybind helpers", () => {
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
})
test("matchKeybind supports bracket keys", () => {
const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
const prev = keybinds[0]
const next = keybinds[1]
expect(
matchKeybind(
keybinds,
new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
),
).toBe(true)
expect(
matchKeybind(
keybinds,
new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
),
).toBe(true)
})
test("formatKeybind returns human readable output", () => {
const display = formatKeybind("ctrl+alt+arrowup")
+3 -8
View File
@@ -97,15 +97,10 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError())
}
const localUrl = () =>
`http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname)
const getCurrentUrl = () => {
if (location.hostname.includes("opencode.ai")) return localUrl()
if (import.meta.env.DEV) return localUrl()
if (isLocalHost()) return localUrl()
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
}
+2
View File
@@ -965,6 +965,8 @@ export default function Layout(props: ParentProps) {
: projects[(index + offset + projects.length) % projects.length]
if (!target) return
// warm up child store to prevent flicker
globalSync.child(target.worktree)
openProject(target.worktree)
}
+30 -20
View File
@@ -4,7 +4,6 @@ import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
@@ -15,6 +14,7 @@ import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { SpinnerLabHeader } from "@/pages/session/spinner-lab"
import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers"
@@ -115,26 +115,36 @@ const SessionRow = (props: {
props.clearHoverProjectSoon()
}}
>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
<Show
when={props.isWorking()}
fallback={
<>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
</>
}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
<SpinnerLabHeader
title={props.session.title}
tint={props.tint() ?? "var(--icon-interactive-base)"}
class="min-w-0 flex-1"
/>
</Show>
</A>
)
+5 -1
View File
@@ -15,6 +15,7 @@ type TabsInput = {
normalizeTab: (tab: string) => string
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fixed?: Accessor<string[]>
}
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
@@ -22,6 +23,7 @@ export const getSessionKey = (dir: string | undefined, id: string | undefined) =
export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
const fixed = input.fixed ?? (() => emptyTabs)
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
const openedTabs = createMemo(
() => {
@@ -30,7 +32,7 @@ export const createSessionTabs = (input: TabsInput) => {
.tabs()
.all()
.flatMap((tab) => {
if (tab === "context" || tab === "review") return []
if (tab === "context" || tab === "review" || fixed().includes(tab)) return []
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
if (seen.has(value)) return []
seen.add(value)
@@ -44,6 +46,7 @@ export const createSessionTabs = (input: TabsInput) => {
const active = input.tabs().active()
if (active === "context") return active
if (active === "review" && review()) return active
if (active && fixed().includes(active)) return active
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
const first = openedTabs()[0]
@@ -60,6 +63,7 @@ export const createSessionTabs = (input: TabsInput) => {
const closableTab = createMemo(() => {
const active = activeTab()
if (active === "context") return active
if (fixed().includes(active)) return
if (!openedTabs().includes(active)) return
return active
})
@@ -9,7 +9,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { TextField } from "@opencode-ai/ui/text-field"
@@ -19,6 +18,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SpinnerLabHeader } from "@/pages/session/spinner-lab"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
@@ -657,33 +657,31 @@ export function MessageTimeline(props: {
/>
</Show>
<div class="flex items-center min-w-0 grow-1">
<div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{
width: working() ? "16px" : "0px",
"margin-right": working() ? "8px" : "0px",
}}
aria-hidden="true"
>
<Show when={workingStatus() !== "hidden"}>
<div
class="transition-opacity duration-200 ease-out"
classList={{ "opacity-0": workingStatus() === "hiding" }}
>
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
</div>
</Show>
</div>
<Show when={titleValue() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
<Show
when={workingStatus() !== "hidden"}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
>
{titleValue()}
</h1>
}
>
{titleValue()}
</h1>
<div
class="min-w-0 grow-1 transition-opacity duration-200 ease-out"
classList={{ "opacity-0": workingStatus() === "hiding" }}
>
<SpinnerLabHeader
title={titleValue() ?? ""}
tint={tint() ?? "var(--icon-interactive-base)"}
/>
</div>
</Show>
}
>
<InlineInput
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,484 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Braille, getBrailleFrames, type BrailleKind } from "@/components/pendulum"
export const spinnerLabIds = [
"current",
"pendulum-sweep",
"pendulum",
"pendulum-glow",
"compress-sweep",
"compress",
"compress-flash",
"sort-sweep",
"sort",
"sort-spark",
"pendulum-replace",
"compress-replace",
"sort-replace",
"pendulum-sweep-replace",
"compress-flash-replace",
"sort-spark-replace",
"pendulum-glow-replace",
"compress-sweep-replace",
"sort-sweep-replace",
"pendulum-overlay",
"compress-overlay",
"sort-overlay",
"pendulum-glow-overlay",
"sort-spark-overlay",
"pendulum-frame",
"compress-frame",
"compress-tail",
"sort-frame",
"square-wave",
] as const
export type SpinnerLabId = (typeof spinnerLabIds)[number]
const ids = new Set<string>(spinnerLabIds)
const trailFrames = (cols: number) => {
let s = 17
const rnd = () => {
s = (s * 1664525 + 1013904223) & 0xffffffff
return (s >>> 0) / 0xffffffff
}
return Array.from({ length: 120 }, () =>
Array.from({ length: cols }, () => {
let mask = 0
for (let bit = 0; bit < 8; bit++) {
if (rnd() > 0.45) mask |= 1 << bit
}
if (!mask) mask = 1 << Math.floor(rnd() * 8)
return String.fromCharCode(0x2800 + mask)
}).join(""),
)
}
const parse = (id: SpinnerLabId) => {
const kind: BrailleKind | undefined = id.startsWith("pendulum")
? "pendulum"
: id.startsWith("compress")
? "compress"
: id.startsWith("sort")
? "sort"
: undefined
const mode =
id === "current"
? "current"
: id === "square-wave"
? "square"
: id.endsWith("-tail")
? "trail"
: id.endsWith("-replace")
? "replace"
: id.endsWith("-overlay")
? "overlay"
: id.endsWith("-frame")
? "frame"
: id === "pendulum" || id === "compress" || id === "sort"
? "spin"
: "shimmer"
const anim = id.includes("glow")
? 1.4
: id.includes("flash") || id.includes("spark")
? 2.4
: id.includes("sweep")
? 1.9
: 1.8
const move = mode === "spin" || mode === "current" ? 1 : anim
return {
id,
mode,
kind,
cols: mode === "spin" ? 3 : 6,
anim,
move,
color: "#FFE865",
size: 2,
gap: 1,
low: 0.08,
high: 0.72,
}
}
type SpinnerLabTune = ReturnType<typeof parse>
const defaults = Object.fromEntries(spinnerLabIds.map((id) => [id, parse(id)])) as Record<SpinnerLabId, SpinnerLabTune>
const [lab, setLab] = createStore({ active: "pendulum" as SpinnerLabId, tune: defaults })
const mask = (title: string, fill: string, pos: number) =>
Array.from(title)
.map((char, idx) => {
const off = idx - pos
if (off < 0 || off >= fill.length) return char
return fill[off] ?? char
})
.join("")
const Shimmer = (props: {
title: string
kind: BrailleKind
cols: number
anim: number
move: number
color: string
}) => {
const [x, setX] = createSignal(-18)
createEffect(() => {
if (typeof window === "undefined") return
setX(-18)
const id = window.setInterval(() => setX((x) => (x > 112 ? -18 : x + Math.max(0.5, props.move))), 32)
onCleanup(() => window.clearInterval(id))
})
return (
<div class="relative min-w-0 flex-1 overflow-hidden py-0.5">
<div class="truncate text-14-medium text-text-strong">{props.title}</div>
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div class="absolute top-1/2 -translate-y-1/2" style={{ left: `calc(${x()}% - 6ch)` }}>
<Braille
kind={props.kind}
cols={props.cols}
rate={props.anim}
class="inline-flex items-center justify-center overflow-hidden font-mono text-[12px] leading-none font-semibold opacity-80 select-none"
style={{ color: props.color }}
/>
</div>
</div>
</div>
)
}
const Replace = (props: {
title: string
kind: BrailleKind
cols: number
anim: number
move: number
color: string
}) => {
const chars = createMemo(() => Array.from(props.title))
const frames = createMemo(() => getBrailleFrames(props.kind, props.cols))
const [state, setState] = createStore({ pos: 0, idx: 0 })
createEffect(() => {
if (typeof window === "undefined") return
setState({ pos: 0, idx: 0 })
const anim = window.setInterval(
() => setState("idx", (idx) => (idx + 1) % frames().length),
Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
)
const move = window.setInterval(
() => setState("pos", (pos) => (pos >= chars().length - 1 ? 0 : pos + 1)),
Math.max(90, Math.round(260 / Math.max(0.4, props.move))),
)
onCleanup(() => {
window.clearInterval(anim)
window.clearInterval(move)
})
})
return (
<div class="min-w-0 truncate whitespace-nowrap font-mono text-[13px] font-semibold text-text-strong">
{mask(props.title, frames()[state.idx] ?? "", state.pos)}
</div>
)
}
const Overlay = (props: {
title: string
kind: BrailleKind
cols: number
anim: number
move: number
color: string
}) => {
let root: HTMLDivElement | undefined
let fx: HTMLDivElement | undefined
const [state, setState] = createStore({ pos: 0, max: 0, dark: false })
createEffect(() => {
if (typeof window === "undefined") return
setState({ pos: 0 })
const id = window.setInterval(
() => setState("pos", (pos) => (pos >= state.max ? 0 : Math.min(state.max, pos + 8))),
Math.max(90, Math.round(260 / Math.max(0.4, props.move))),
)
onCleanup(() => window.clearInterval(id))
})
createEffect(() => {
if (typeof window === "undefined") return
if (!root || !fx) return
const sync = () => setState("max", Math.max(0, root!.clientWidth - fx!.clientWidth))
sync()
const observer = new ResizeObserver(sync)
observer.observe(root)
observer.observe(fx)
onCleanup(() => observer.disconnect())
})
createEffect(() => {
if (typeof window === "undefined") return
const query = window.matchMedia("(prefers-color-scheme: dark)")
const sync = () => setState("dark", query.matches)
sync()
query.addEventListener("change", sync)
onCleanup(() => query.removeEventListener("change", sync))
})
return (
<div ref={root} class="relative min-w-0 flex-1 overflow-hidden py-0.5">
<div class="truncate text-14-medium text-text-strong">{props.title}</div>
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div ref={fx} class="absolute top-1/2 -translate-y-1/2" style={{ left: `${state.pos}px` }}>
<Braille
kind={props.kind}
cols={props.cols}
rate={props.anim}
class="inline-flex items-center justify-center overflow-hidden rounded-sm px-0.5 py-2 font-mono text-[12px] leading-none font-semibold select-none"
style={{ color: props.color, "background-color": state.dark ? "#151515" : "#FCFCFC" }}
/>
</div>
</div>
</div>
)
}
const Frame = (props: { title: string; kind: BrailleKind; cols: number; anim: number; color: string }) => {
const head = createMemo(() => getBrailleFrames(props.kind, props.cols))
const tail = createMemo(() => getBrailleFrames(props.kind, 64))
const [state, setState] = createStore({ idx: 0 })
createEffect(() => {
if (typeof window === "undefined") return
setState({ idx: 0 })
const id = window.setInterval(
() => setState("idx", (idx) => (idx + 1) % head().length),
Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
)
onCleanup(() => window.clearInterval(id))
})
return (
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-0.5">
<div class="shrink-0 font-mono text-[12px] font-semibold leading-none" style={{ color: props.color }}>
{head()[state.idx] ?? ""}
</div>
<div class="shrink-0 truncate text-14-medium text-text-strong">{props.title}</div>
<div
class="min-w-0 flex-1 overflow-hidden whitespace-nowrap font-mono text-[12px] font-semibold leading-none"
style={{ color: props.color }}
>
{tail()[state.idx] ?? ""}
</div>
</div>
)
}
const Trail = (props: { title: string; kind: BrailleKind; cols: number; anim: number; color: string }) => {
const tail = createMemo(() => trailFrames(Math.max(24, props.cols * 12)))
const [state, setState] = createStore({ idx: 0 })
createEffect(() => {
if (typeof window === "undefined") return
setState({ idx: 0 })
const id = window.setInterval(
() => setState("idx", (idx) => (idx + 1) % tail().length),
Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
)
onCleanup(() => window.clearInterval(id))
})
return (
<div class="flex w-full min-w-0 flex-1 items-center gap-2 overflow-hidden py-0.5">
<div class="min-w-0 max-w-[55%] flex-[0_1_auto] truncate text-14-medium text-text-strong">{props.title}</div>
<div
class="min-w-[10ch] basis-0 flex-[1_1_0%] overflow-hidden whitespace-nowrap font-mono text-[12px] font-semibold leading-none"
style={{ color: props.color }}
>
{tail()[state.idx] ?? ""}
</div>
</div>
)
}
const Square = (props: {
title: string
anim: number
move: number
color: string
size: number
gap: number
low: number
high: number
}) => {
const cols = createMemo(() => Math.max(96, Math.ceil(Array.from(props.title).length * 4.5)))
const cells = createMemo(() =>
Array.from({ length: cols() * 4 }, (_, idx) => ({ row: Math.floor(idx / cols()), col: idx % cols() })),
)
const [state, setState] = createStore({ pos: 0, phase: 0 })
createEffect(() => {
if (typeof window === "undefined") return
setState({ pos: 0, phase: 0 })
const anim = window.setInterval(
() => setState("phase", (phase) => phase + 0.45),
Math.max(16, Math.round(44 / Math.max(0.4, props.anim))),
)
const move = window.setInterval(
() => setState("pos", (pos) => (pos >= cols() + 10 ? 0 : pos + 1)),
Math.max(40, Math.round(160 / Math.max(0.4, props.move))),
)
onCleanup(() => {
window.clearInterval(anim)
window.clearInterval(move)
})
})
return (
<div class="relative min-w-0 flex-1 overflow-hidden py-2">
<div
class="pointer-events-none absolute inset-0 grid content-center overflow-hidden"
aria-hidden="true"
style={{
"grid-template-columns": `repeat(${cols()}, ${props.size}px)`,
"grid-auto-rows": `${props.size}px`,
gap: `${props.gap}px`,
}}
>
<For each={cells()}>
{(cell) => {
const opacity = () => {
const wave = (Math.cos((cell.col - state.pos) * 0.32 - state.phase + cell.row * 0.55) + 1) / 2
return props.low + (props.high - props.low) * wave * wave
}
return (
<div
style={{
width: `${props.size}px`,
height: `${props.size}px`,
"background-color": props.color,
opacity: `${opacity()}`,
}}
/>
)
}}
</For>
</div>
<div class="relative z-10 truncate px-2 text-14-medium text-text-strong">
<span class="bg-background-stronger">{props.title}</span>
</div>
</div>
)
}
export const selectSpinnerLab = (id: string) => {
if (!ids.has(id)) return
setLab("active", id as SpinnerLabId)
}
export const useSpinnerLab = () => ({
active: () => lab.active,
isActive: (id: string) => lab.active === id,
tune: lab.tune,
config: (id: SpinnerLabId) => lab.tune[id],
current: () => lab.tune[lab.active],
setTune: <K extends keyof SpinnerLabTune>(id: SpinnerLabId, key: K, value: SpinnerLabTune[K]) =>
setLab("tune", id, key, value),
})
export function SpinnerLabHeader(props: { title: string; tint?: string; class?: string }) {
const cfg = createMemo(() => lab.tune[lab.active])
const body = createMemo<JSX.Element>(() => {
const cur = cfg()
if (cur.mode === "current") {
return (
<div class="flex min-w-0 items-center gap-2">
<Spinner class="size-4" style={{ color: props.tint ?? cur.color }} />
<div class="min-w-0 truncate text-14-medium text-text-strong">{props.title}</div>
</div>
)
}
if (cur.mode === "spin" && cur.kind) {
return (
<div class="flex min-w-0 items-center gap-2">
<Braille
kind={cur.kind}
cols={cur.cols}
rate={cur.anim}
class="inline-flex w-4 items-center justify-center overflow-hidden font-mono text-[9px] leading-none select-none"
style={{ color: cur.color }}
/>
<div class="min-w-0 truncate text-14-medium text-text-strong">{props.title}</div>
</div>
)
}
if (cur.mode === "shimmer" && cur.kind) {
return (
<Shimmer
title={props.title}
kind={cur.kind}
cols={cur.cols}
anim={cur.anim}
move={cur.move}
color={cur.color}
/>
)
}
if (cur.mode === "replace" && cur.kind) {
return (
<Replace
title={props.title}
kind={cur.kind}
cols={cur.cols}
anim={cur.anim}
move={cur.move}
color={cur.color}
/>
)
}
if (cur.mode === "overlay" && cur.kind) {
return (
<Overlay
title={props.title}
kind={cur.kind}
cols={cur.cols}
anim={cur.anim}
move={cur.move}
color={cur.color}
/>
)
}
if (cur.mode === "trail" && cur.kind) {
return <Trail title={props.title} kind={cur.kind} cols={cur.cols} anim={cur.anim} color={cur.color} />
}
if (cur.mode === "frame" && cur.kind) {
return <Frame title={props.title} kind={cur.kind} cols={cur.cols} anim={cur.anim} color={cur.color} />
}
return (
<Square
title={props.title}
anim={cur.anim}
move={cur.move}
color={cur.color}
size={cur.size}
gap={cur.gap}
low={cur.low}
high={cur.high}
/>
)
})
return <div class={props.class ?? "min-w-0 grow-1 w-full"}>{body()}</div>
}
@@ -333,7 +333,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+arrowup",
keybind: "mod+alt+[",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
}),
@@ -341,7 +341,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+arrowdown",
keybind: "mod+alt+]",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
}),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.3.1",
"version": "1.3.2",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.3.1",
"version": "1.3.2",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.3.1",
"version": "1.3.2",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.3.1",
"version": "1.3.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.3.1",
"version": "1.3.2",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -88,7 +88,7 @@ export function registerIpcHandlers(deps: Deps) {
"open-directory-picker",
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : [])],
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
title: opts?.title ?? "Choose a folder",
defaultPath: opts?.defaultPath,
})
+1 -1
View File
@@ -7,7 +7,7 @@ const cache = new Map<string, Store>()
export function getStore(name = SETTINGS_STORE) {
const cached = cache.get(name)
if (cached) return cached
const next = new Store({ name })
const next = new Store({ name, fileExtension: "" })
cache.set(name, next)
return next
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.3.1",
"version": "1.3.2",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.3.1",
"version": "1.3.2",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.3.1"
version = "1.3.2"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.1/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.1/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.1/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.1/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.1/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.3.1",
"version": "1.3.2",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.3.1",
"version": "1.3.2",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -133,9 +133,9 @@
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"opencode-poe-auth": "0.0.1",
"remeda": "catalog:",
"semver": "^7.6.3",
"solid-js": "catalog:",
+7 -3
View File
@@ -21,10 +21,14 @@ const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.ts"),
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
path.join(dir, "src/provider/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
)
console.log("Generated models-snapshot.ts")
await Bun.write(
path.join(dir, "src/provider/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")
// Load migrations from migration directories
const migrationDirs = (
+1 -1
View File
@@ -164,7 +164,7 @@ Still open and likely worth migrating:
- [x] `Plugin`
- [x] `ToolRegistry`
- [ ] `Pty`
- [ ] `Worktree`
- [x] `Worktree`
- [ ] `Bus`
- [x] `Command`
- [ ] `Config`
+6 -5
View File
@@ -110,6 +110,7 @@ export function tui(input: {
url: string
args: Args
config: TuiConfig.Info
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
@@ -160,7 +161,7 @@ export function tui(input: {
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
<App onSnapshot={input.onSnapshot} />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
@@ -201,7 +202,7 @@ export function tui(input: {
})
}
function App() {
function App(props: { onSnapshot?: () => Promise<string[]> }) {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
@@ -627,11 +628,11 @@ function App() {
title: "Write heap snapshot",
category: "System",
value: "app.heap_snapshot",
onSelect: (dialog) => {
const path = writeHeapSnapshot()
onSelect: async (dialog) => {
const files = await props.onSnapshot?.()
toast.show({
variant: "info",
message: `Heap snapshot written to ${path}`,
message: `Heap snapshot written to ${files?.join(", ")}`,
duration: 5000,
})
dialog.clear()
@@ -14,6 +14,7 @@ import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { writeHeapSnapshot } from "v8"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -201,6 +202,11 @@ export const TuiThreadCommand = cmd({
try {
await tui({
url: transport.url,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
directory: cwd,
fetch: transport.fetch,
@@ -10,6 +10,7 @@ import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
import { writeHeapSnapshot } from "node:v8"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -117,6 +118,10 @@ export const rpc = {
body,
}
},
snapshot() {
const result = writeHeapSnapshot("server.heapsnapshot")
return result
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true)
server = await Server.listen(input)
@@ -22,12 +22,11 @@ export const WorktreeAdaptor: Adaptor = {
},
async create(info) {
const config = Config.parse(info)
const bootstrap = await Worktree.createFromInfo({
await Worktree.createFromInfo({
name: config.name,
directory: config.directory,
branch: config.branch,
})
return bootstrap()
},
async remove(info) {
const config = Config.parse(info)
+6
View File
@@ -177,6 +177,12 @@ export namespace LSP {
async function getClients(file: string) {
const s = await state()
// Only spawn LSP clients for files within the instance directory
if (!Instance.containsPath(file)) {
return []
}
const extension = path.parse(file).ext || file
const result: LSPClient.Info[] = []
+2
View File
@@ -0,0 +1,2 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -89,7 +89,7 @@ export namespace ModelsDev {
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
if (result) return result
// @ts-ignore
const snapshot = await import("./models-snapshot")
const snapshot = await import("./models-snapshot.js")
.then((m) => m.snapshot as Record<string, unknown>)
.catch(() => undefined)
if (snapshot) return snapshot
@@ -108,7 +108,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
validator("json", Worktree.create.schema),
validator("json", Worktree.CreateInput.optional()),
async (c) => {
const body = c.req.valid("json")
const worktree = await Worktree.create(body)
@@ -155,7 +155,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
validator("json", Worktree.remove.schema),
validator("json", Worktree.RemoveInput),
async (c) => {
const body = c.req.valid("json")
await Worktree.remove(body)
@@ -181,7 +181,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
validator("json", Worktree.reset.schema),
validator("json", Worktree.ResetInput),
async (c) => {
const body = c.req.valid("json")
await Worktree.reset(body)
+11 -4
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto"
import { Log } from "../util/log"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
@@ -47,6 +48,9 @@ import { lazy } from "@/util/lazy"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
export namespace Server {
const log = Log.create({ service: "server" })
@@ -506,10 +510,13 @@ export namespace Server {
host: "app.opencode.ai",
},
})
response.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
)
const match = response.headers.get("content-type")?.includes("text/html")
? (await response.clone().text()).match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
: undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash))
return response
})
}
+59 -9
View File
@@ -34,6 +34,7 @@ export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const prune = "7.days"
const limit = 2 * 1024 * 1024
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const cfg = ["-c", "core.autocrlf=false", ...core]
const quote = [...cfg, "-c", "core.quotepath=false"]
@@ -123,20 +124,69 @@ export namespace Snapshot {
return file
})
const sync = Effect.fnUntraced(function* () {
const sync = Effect.fnUntraced(function* (list: string[] = []) {
const file = yield* excludes()
const target = path.join(state.gitdir, "info", "exclude")
const text = [
file ? (yield* read(file)).trimEnd() : "",
...list.map((item) => `/${item.replaceAll("\\", "/")}`),
]
.filter(Boolean)
.join("\n")
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
if (!file) {
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
return
}
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
})
const add = Effect.fnUntraced(function* () {
yield* sync()
yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory })
const [diff, other] = yield* Effect.all(
[
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
cwd: state.directory,
}),
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
cwd: state.directory,
}),
],
{ concurrency: 2 },
)
if (diff.code !== 0 || other.code !== 0) {
log.warn("failed to list snapshot files", {
diffCode: diff.code,
diffStderr: diff.stderr,
otherCode: other.code,
otherStderr: other.stderr,
})
return
}
const tracked = diff.text.split("\0").filter(Boolean)
const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
if (!all.length) return
const large = (yield* Effect.all(
all.map((item) =>
fs
.stat(path.join(state.directory, item))
.pipe(Effect.catch(() => Effect.void))
.pipe(
Effect.map((stat) => {
if (!stat || stat.type !== "File") return
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
return size > limit ? item : undefined
}),
),
),
{ concurrency: 8 },
)).filter((item): item is string => Boolean(item))
yield* sync(large)
const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
if (result.code !== 0) {
log.warn("failed to add snapshot files", {
exitCode: result.code,
stderr: result.stderr,
})
}
})
const cleanup = Effect.fnUntraced(function* () {
@@ -177,7 +227,7 @@ export namespace Snapshot {
const patch = Effect.fnUntraced(function* (hash: string) {
yield* add()
const result = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
{
cwd: state.directory,
},
@@ -245,7 +295,7 @@ export namespace Snapshot {
const diff = Effect.fnUntraced(function* (hash: string) {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
cwd: state.worktree,
})
if (result.code !== 0) {
+418 -459
View File
@@ -1,5 +1,3 @@
import fs from "fs/promises"
import path from "path"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global"
@@ -9,12 +7,15 @@ import { Project } from "../project/project"
import { Database, eq } from "../storage/db"
import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema"
import { fn } from "../util/fn"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { git } from "../util/git"
import { Slug } from "@opencode-ai/util/slug"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRunPromise } from "@/effect/run-service"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
export namespace Worktree {
const log = Log.create({ service: "worktree" })
@@ -123,77 +124,7 @@ export namespace Worktree {
}),
)
const ADJECTIVES = [
"brave",
"calm",
"clever",
"cosmic",
"crisp",
"curious",
"eager",
"gentle",
"glowing",
"happy",
"hidden",
"jolly",
"kind",
"lucky",
"mighty",
"misty",
"neon",
"nimble",
"playful",
"proud",
"quick",
"quiet",
"shiny",
"silent",
"stellar",
"sunny",
"swift",
"tidy",
"witty",
] as const
const NOUNS = [
"cabin",
"cactus",
"canyon",
"circuit",
"comet",
"eagle",
"engine",
"falcon",
"forest",
"garden",
"harbor",
"island",
"knight",
"lagoon",
"meadow",
"moon",
"mountain",
"nebula",
"orchid",
"otter",
"panda",
"pixel",
"planet",
"river",
"rocket",
"sailor",
"squid",
"star",
"tiger",
"wizard",
"wolf",
] as const
function pick<const T extends readonly string[]>(list: T) {
return list[Math.floor(Math.random() * list.length)]
}
function slug(input: string) {
function slugify(input: string) {
return input
.trim()
.toLowerCase()
@@ -202,28 +133,8 @@ export namespace Worktree {
.replace(/-+$/, "")
}
function randomName() {
return `${pick(ADJECTIVES)}-${pick(NOUNS)}`
}
async function exists(target: string) {
return fs
.stat(target)
.then(() => true)
.catch(() => false)
}
function outputText(input: Uint8Array | undefined) {
if (!input?.length) return ""
return new TextDecoder().decode(input).trim()
}
function errorText(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
}
function failed(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).flatMap((chunk) =>
function failedRemoves(...chunks: string[]) {
return chunks.filter(Boolean).flatMap((chunk) =>
chunk
.split("\n")
.map((line) => line.trim())
@@ -237,436 +148,484 @@ export namespace Worktree {
)
}
async function prune(root: string, entries: string[]) {
const base = await canonical(root)
await Promise.all(
entries.map(async (entry) => {
const target = await canonical(path.resolve(root, entry))
if (target === base) return
if (!target.startsWith(`${base}${path.sep}`)) return
await fs.rm(target, { recursive: true, force: true }).catch(() => undefined)
}),
)
// ---------------------------------------------------------------------------
// Effect service
// ---------------------------------------------------------------------------
export interface Interface {
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
readonly create: (input?: CreateInput) => Effect.Effect<Info>
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
}
async function sweep(root: string) {
const first = await git(["clean", "-ffdx"], { cwd: root })
if (first.exitCode === 0) return first
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Worktree") {}
const entries = failed(first)
if (!entries.length) return first
type GitResult = { code: number; text: string; stderr: string }
await prune(root, entries)
return git(["clean", "-ffdx"], { cwd: root })
}
export const layer: Layer.Layer<
Service,
never,
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const scope = yield* Scope.Scope
const fsys = yield* FileSystem.FileSystem
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
async function canonical(input: string) {
const abs = path.resolve(input)
const real = await fs.realpath(abs).catch(() => abs)
const normalized = path.normalize(real)
return process.platform === "win32" ? normalized.toLowerCase() : normalized
}
const git = Effect.fnUntraced(
function* (args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch((e) =>
Effect.succeed({ code: 1, text: "", stderr: e instanceof Error ? e.message : String(e) } satisfies GitResult),
),
)
async function candidate(root: string, base?: string) {
for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
const branch = `opencode/${name}`
const directory = path.join(root, name)
const MAX_NAME_ATTEMPTS = 26
const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create()
const branch = `opencode/${name}`
const directory = pathSvc.join(root, name)
if (await exists(directory)) continue
if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue
const ref = `refs/heads/${branch}`
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
cwd: Instance.worktree,
const ref = `refs/heads/${branch}`
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
if (branchCheck.code === 0) continue
return Info.parse({ name, branch, directory })
}
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
})
if (branchCheck.exitCode === 0) continue
return Info.parse({ name, branch, directory })
}
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
}
const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
async function runStartCommand(directory: string, cmd: string) {
if (process.platform === "win32") {
return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
}
return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
}
type StartKind = "project" | "worktree"
async function runStartScript(directory: string, cmd: string, kind: StartKind) {
const text = cmd.trim()
if (!text) return true
const ran = await runStartCommand(directory, text)
if (ran.code === 0) return true
log.error("worktree start command failed", {
kind,
directory,
message: errorText(ran),
})
return false
}
async function runStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get())
const project = row ? Project.fromRow(row) : undefined
const startup = project?.commands?.start?.trim() ?? ""
const ok = await runStartScript(directory, startup, "project")
if (!ok) return false
const extra = input.extra ?? ""
await runStartScript(directory, extra, "worktree")
return true
}
function queueStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
setTimeout(() => {
const start = async () => {
await runStartScripts(directory, input)
}
void start().catch((error) => {
log.error("worktree start task failed", { directory, error })
const base = name ? slugify(name) : ""
return yield* candidate(root, base || undefined)
})
}, 0)
}
export async function makeWorktreeInfo(name?: string): Promise<Info> {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const setup = Effect.fnUntraced(function* (info: Info) {
const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
cwd: Instance.worktree,
})
if (created.code !== 0) {
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
}
const root = path.join(Global.Path.data, "worktree", Instance.project.id)
await fs.mkdir(root, { recursive: true })
yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined))
})
const base = name ? slug(name) : ""
return candidate(root, base || undefined)
}
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
const projectID = Instance.project.id
const extra = startCommand?.trim()
export async function createFromInfo(info: Info, startCommand?: string) {
const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
cwd: Instance.worktree,
})
if (created.exitCode !== 0) {
throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
}
await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)
const projectID = Instance.project.id
const extra = startCommand?.trim()
return () => {
const start = async () => {
const populated = await git(["reset", "--hard"], { cwd: info.directory })
if (populated.exitCode !== 0) {
const message = errorText(populated) || "Failed to populate worktree"
const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
if (populated.code !== 0) {
const message = populated.stderr || populated.text || "Failed to populate worktree"
log.error("worktree checkout failed", { directory: info.directory, message })
GlobalBus.emit("event", {
directory: info.directory,
payload: {
type: Event.Failed.type,
properties: {
message,
},
},
payload: { type: Event.Failed.type, properties: { message } },
})
return
}
const booted = await Instance.provide({
directory: info.directory,
init: InstanceBootstrap,
fn: () => undefined,
})
.then(() => true)
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
log.error("worktree bootstrap failed", { directory: info.directory, message })
GlobalBus.emit("event", {
directory: info.directory,
payload: {
type: Event.Failed.type,
properties: {
message,
},
},
})
return false
const booted = yield* Effect.promise(() =>
Instance.provide({
directory: info.directory,
init: InstanceBootstrap,
fn: () => undefined,
})
.then(() => true)
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
log.error("worktree bootstrap failed", { directory: info.directory, message })
GlobalBus.emit("event", {
directory: info.directory,
payload: { type: Event.Failed.type, properties: { message } },
})
return false
}),
)
if (!booted) return
GlobalBus.emit("event", {
directory: info.directory,
payload: {
type: Event.Ready.type,
properties: {
name: info.name,
branch: info.branch,
},
properties: { name: info.name, branch: info.branch },
},
})
await runStartScripts(info.directory, { projectID, extra })
yield* runStartScripts(info.directory, { projectID, extra })
})
const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
yield* setup(info)
yield* boot(info, startCommand)
})
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
const info = yield* makeWorktreeInfo(input?.name)
yield* setup(info)
yield* boot(info, input?.startCommand).pipe(
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
Effect.forkIn(scope),
)
return info
})
const canonical = Effect.fnUntraced(function* (input: string) {
const abs = pathSvc.resolve(input)
const real = yield* fsys.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
const normalized = pathSvc.normalize(real)
return process.platform === "win32" ? normalized.toLowerCase() : normalized
})
function parseWorktreeList(text: string) {
return text
.split("\n")
.map((line) => line.trim())
.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
if (!line) return acc
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
return acc
}
const current = acc[acc.length - 1]
if (!current) return acc
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
return acc
}, [])
}
return start().catch((error) => {
log.error("worktree start task failed", { directory: info.directory, error })
})
}
}
export const create = fn(CreateInput.optional(), async (input) => {
const info = await makeWorktreeInfo(input?.name)
const bootstrap = await createFromInfo(info, input?.startCommand)
// This is needed due to how worktrees currently work in the
// desktop app
setTimeout(() => {
bootstrap()
}, 0)
return info
})
export const remove = fn(RemoveInput, async (input) => {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = await canonical(input.directory)
const locate = async (stdout: Uint8Array | undefined) => {
const lines = outputText(stdout)
.split("\n")
.map((line) => line.trim())
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
if (!line) return acc
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
return acc
}
const current = acc[acc.length - 1]
if (!current) return acc
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
return acc
}, [])
return (async () => {
const locateWorktree = Effect.fnUntraced(function* (
entries: { path?: string; branch?: string }[],
directory: string,
) {
for (const item of entries) {
if (!item.path) continue
const key = await canonical(item.path)
const key = yield* canonical(item.path)
if (key === directory) return item
}
})()
}
return undefined
})
const clean = (target: string) =>
fs
.rm(target, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
})
const stop = async (target: string) => {
if (!(await exists(target))) return
await git(["fsmonitor--daemon", "stop"], { cwd: target })
}
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
const entry = await locate(list.stdout)
if (!entry?.path) {
const directoryExists = await exists(directory)
if (directoryExists) {
await stop(directory)
await clean(directory)
}
return true
}
await stop(entry.path)
const removed = await git(["worktree", "remove", "--force", entry.path], {
cwd: Instance.worktree,
})
if (removed.exitCode !== 0) {
const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (next.exitCode !== 0) {
throw new RemoveFailedError({
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
})
function stopFsmonitor(target: string) {
return fsys.exists(target).pipe(
Effect.orDie,
Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
)
}
const stale = await locate(next.stdout)
if (stale?.path) {
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
function cleanDirectory(target: string) {
return Effect.promise(() =>
import("fs/promises").then((fsp) =>
fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
),
)
}
}
await clean(entry.path)
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
if (deleted.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
}
}
const directory = yield* canonical(input.directory)
return true
})
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.code !== 0) {
throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
}
export const reset = fn(ResetInput, async (input) => {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const entries = parseWorktreeList(list.text)
const entry = yield* locateWorktree(entries, directory)
const directory = await canonical(input.directory)
const primary = await canonical(Instance.worktree)
if (directory === primary) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
if (!entry?.path) {
const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie)
if (directoryExists) {
yield* stopFsmonitor(directory)
yield* cleanDirectory(directory)
}
return true
}
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
yield* stopFsmonitor(entry.path)
const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree })
if (removed.code !== 0) {
const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (next.code !== 0) {
throw new RemoveFailedError({
message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
})
}
const lines = outputText(list.stdout)
.split("\n")
.map((line) => line.trim())
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
if (!line) return acc
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
return acc
}
const current = acc[acc.length - 1]
if (!current) return acc
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
return acc
}, [])
const stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
if (stale?.path) {
throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
}
}
const entry = await (async () => {
for (const item of entries) {
if (!item.path) continue
const key = await canonical(item.path)
if (key === directory) return item
}
})()
if (!entry?.path) {
throw new ResetFailedError({ message: "Worktree not found" })
}
yield* cleanDirectory(entry.path)
const remoteList = await git(["remote"], { cwd: Instance.worktree })
if (remoteList.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
}
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree })
if (deleted.code !== 0) {
throw new RemoveFailedError({
message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
})
}
}
const remotes = outputText(remoteList.stdout)
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
return true
})
const remote = remotes.includes("origin")
? "origin"
: remotes.length === 1
? remotes[0]
: remotes.includes("upstream")
? "upstream"
: ""
const gitExpect = Effect.fnUntraced(function* (
args: string[],
opts: { cwd: string },
error: (r: GitResult) => Error,
) {
const result = yield* git(args, opts)
if (result.code !== 0) throw error(result)
return result
})
const remoteHead = remote
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
: { exitCode: 1, stdout: undefined, stderr: undefined }
const runStartCommand = Effect.fnUntraced(
function* (directory: string, cmd: string) {
const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]]
const handle = yield* spawner.spawn(
ChildProcess.make(shell, args, { cwd: directory, extendEnv: true, stdin: "ignore" }),
)
// Drain stdout, capture stderr for error reporting
const [, stderr] = yield* Effect.all(
[Stream.runDrain(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
).pipe(Effect.orDie)
const code = yield* handle.exitCode
return { code, stderr }
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: 1, stderr: "" })),
)
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) {
const text = cmd.trim()
if (!text) return true
const result = yield* runStartCommand(directory, text)
if (result.code === 0) return true
log.error("worktree start command failed", { kind, directory, message: result.stderr })
return false
})
const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
cwd: Instance.worktree,
})
const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
cwd: Instance.worktree,
})
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
const runStartScripts = Effect.fnUntraced(function* (
directory: string,
input: { projectID: ProjectID; extra?: string },
) {
const row = yield* Effect.sync(() =>
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()),
)
const project = row ? Project.fromRow(row) : undefined
const startup = project?.commands?.start?.trim() ?? ""
const ok = yield* runStartScript(directory, startup, "project")
if (!ok) return false
yield* runStartScript(directory, input.extra ?? "", "worktree")
return true
})
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
if (!target) {
throw new ResetFailedError({ message: "Default branch not found" })
}
const prune = Effect.fnUntraced(function* (root: string, entries: string[]) {
const base = yield* canonical(root)
yield* Effect.forEach(
entries,
(entry) =>
Effect.gen(function* () {
const target = yield* canonical(pathSvc.resolve(root, entry))
if (target === base) return
if (!target.startsWith(`${base}${pathSvc.sep}`)) return
yield* fsys.remove(target, { recursive: true }).pipe(Effect.ignore)
}),
{ concurrency: "unbounded" },
)
})
if (remoteBranch) {
const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
if (fetch.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
}
}
const sweep = Effect.fnUntraced(function* (root: string) {
const first = yield* git(["clean", "-ffdx"], { cwd: root })
if (first.code === 0) return first
if (!entry.path) {
throw new ResetFailedError({ message: "Worktree path not found" })
}
const entries = failedRemoves(first.stderr, first.text)
if (!entries.length) return first
const worktreePath = entry.path
yield* prune(root, entries)
return yield* git(["clean", "-ffdx"], { cwd: root })
})
const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
if (resetToTarget.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
}
const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const clean = await sweep(worktreePath)
if (clean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
}
const directory = yield* canonical(input.directory)
const primary = yield* canonical(Instance.worktree)
if (directory === primary) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
if (update.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
}
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.code !== 0) {
throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
}
const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
cwd: worktreePath,
})
if (subReset.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
}
const entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
if (!entry?.path) {
throw new ResetFailedError({ message: "Worktree not found" })
}
const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
cwd: worktreePath,
})
if (subClean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
}
const worktreePath = entry.path
const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
if (status.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
}
const remoteList = yield* git(["remote"], { cwd: Instance.worktree })
if (remoteList.code !== 0) {
throw new ResetFailedError({ message: remoteList.stderr || remoteList.text || "Failed to list git remotes" })
}
const dirty = outputText(status.stdout)
if (dirty) {
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
}
const remotes = remoteList.text
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
const remote = remotes.includes("origin")
? "origin"
: remotes.length === 1
? remotes[0]
: remotes.includes("upstream")
? "upstream"
: ""
const projectID = Instance.project.id
queueStartScripts(worktreePath, { projectID })
const remoteHead = remote
? yield* git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
: { code: 1, text: "", stderr: "" }
return true
})
const remoteRef = remoteHead.code === 0 ? remoteHead.text.trim() : ""
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
const remoteBranch =
remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
const [mainCheck, masterCheck] = yield* Effect.all(
[
git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree }),
git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree }),
],
{ concurrency: 2 },
)
const localBranch = mainCheck.code === 0 ? "main" : masterCheck.code === 0 ? "master" : ""
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
if (!target) {
throw new ResetFailedError({ message: "Default branch not found" })
}
if (remoteBranch) {
yield* gitExpect(
["fetch", remote, remoteBranch],
{ cwd: Instance.worktree },
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${target}` }),
)
}
yield* gitExpect(
["reset", "--hard", target],
{ cwd: worktreePath },
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
)
const cleanResult = yield* sweep(worktreePath)
if (cleanResult.code !== 0) {
throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" })
}
yield* gitExpect(
["submodule", "update", "--init", "--recursive", "--force"],
{ cwd: worktreePath },
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to update submodules" }),
)
yield* gitExpect(
["submodule", "foreach", "--recursive", "git", "reset", "--hard"],
{ cwd: worktreePath },
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset submodules" }),
)
yield* gitExpect(
["submodule", "foreach", "--recursive", "git", "clean", "-fdx"],
{ cwd: worktreePath },
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }),
)
const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
if (status.code !== 0) {
throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
}
if (status.text.trim()) {
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
}
yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe(
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))),
Effect.forkIn(scope),
)
return true
})
return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
}),
)
const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
const runPromise = makeRunPromise(Service, defaultLayer)
export async function makeWorktreeInfo(name?: string) {
return runPromise((svc) => svc.makeWorktreeInfo(name))
}
export async function createFromInfo(info: Info, startCommand?: string) {
return runPromise((svc) => svc.createFromInfo(info, startCommand))
}
export async function create(input?: CreateInput) {
return runPromise((svc) => svc.create(input))
}
export async function remove(input: RemoveInput) {
return runPromise((svc) => svc.remove(input))
}
export async function reset(input: ResetInput) {
return runPromise((svc) => svc.reset(input))
}
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, spyOn, test } from "bun:test"
import path from "path"
import * as Lsp from "../../src/lsp/index"
import { LSPServer } from "../../src/lsp/server"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
describe("lsp.spawn", () => {
test("does not spawn builtin LSP for files outside instance", async () => {
await using tmp = await tmpdir()
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Lsp.LSP.touchFile(path.join(tmp.path, "..", "outside.ts"))
await Lsp.LSP.hover({
file: path.join(tmp.path, "..", "hover.ts"),
line: 0,
character: 0,
})
},
})
expect(spy).toHaveBeenCalledTimes(0)
} finally {
spy.mockRestore()
await Instance.disposeAll()
}
})
test("would spawn builtin LSP for files inside instance", async () => {
await using tmp = await tmpdir()
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Lsp.LSP.hover({
file: path.join(tmp.path, "src", "inside.ts"),
line: 0,
character: 0,
})
},
})
expect(spy).toHaveBeenCalledTimes(1)
} finally {
spy.mockRestore()
await Instance.disposeAll()
}
})
})
@@ -0,0 +1,173 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
const wintest = process.platform !== "win32" ? test : test.skip
import fs from "fs/promises"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Worktree } from "../../src/worktree"
import { tmpdir } from "../fixture/fixture"
function withInstance(directory: string, fn: () => Promise<any>) {
return Instance.provide({ directory, fn })
}
function normalize(input: string) {
return input.replace(/\\/g, "/").toLowerCase()
}
async function waitReady() {
const { GlobalBus } = await import("../../src/bus/global")
return await new Promise<{ name: string; branch: string }>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", on)
reject(new Error("timed out waiting for worktree.ready"))
}, 10_000)
function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch: string } } }) {
if (evt.payload.type !== Worktree.Event.Ready.type) return
clearTimeout(timer)
GlobalBus.off("event", on)
resolve(evt.payload.properties)
}
GlobalBus.on("event", on)
})
}
describe("Worktree", () => {
afterEach(() => Instance.disposeAll())
describe("makeWorktreeInfo", () => {
test("returns info with name, branch, and directory", async () => {
await using tmp = await tmpdir({ git: true })
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo())
expect(info.name).toBeDefined()
expect(typeof info.name).toBe("string")
expect(info.branch).toBe(`opencode/${info.name}`)
expect(info.directory).toContain(info.name)
})
test("uses provided name as base", async () => {
await using tmp = await tmpdir({ git: true })
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("my-feature"))
expect(info.name).toBe("my-feature")
expect(info.branch).toBe("opencode/my-feature")
})
test("slugifies the provided name", async () => {
await using tmp = await tmpdir({ git: true })
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("My Feature Branch!"))
expect(info.name).toBe("my-feature-branch")
})
test("throws NotGitError for non-git directories", async () => {
await using tmp = await tmpdir()
await expect(withInstance(tmp.path, () => Worktree.makeWorktreeInfo())).rejects.toThrow("WorktreeNotGitError")
})
})
describe("create + remove lifecycle", () => {
test("create returns worktree info and remove cleans up", async () => {
await using tmp = await tmpdir({ git: true })
const info = await withInstance(tmp.path, () => Worktree.create())
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
expect(info.directory).toBeDefined()
// Wait for bootstrap to complete
await Bun.sleep(1000)
const ok = await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
expect(ok).toBe(true)
})
test("create returns after setup and fires Event.Ready after bootstrap", async () => {
await using tmp = await tmpdir({ git: true })
const ready = waitReady()
const info = await withInstance(tmp.path, () => Worktree.create())
// create returns before bootstrap completes, but the worktree already exists
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
const text = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
const dir = await fs.realpath(info.directory).catch(() => info.directory)
expect(normalize(text)).toContain(normalize(dir))
// Event.Ready fires after bootstrap finishes in the background
const props = await ready
expect(props.name).toBe(info.name)
expect(props.branch).toBe(info.branch)
// Cleanup
await withInstance(info.directory, () => Instance.dispose())
await Bun.sleep(100)
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
test("create with custom name", async () => {
await using tmp = await tmpdir({ git: true })
const ready = waitReady()
const info = await withInstance(tmp.path, () => Worktree.create({ name: "test-workspace" }))
expect(info.name).toBe("test-workspace")
expect(info.branch).toBe("opencode/test-workspace")
// Cleanup
await ready
await withInstance(info.directory, () => Instance.dispose())
await Bun.sleep(100)
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
})
describe("createFromInfo", () => {
wintest("creates and bootstraps git worktree", async () => {
await using tmp = await tmpdir({ git: true })
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("from-info-test"))
await withInstance(tmp.path, () => Worktree.createFromInfo(info))
// Worktree should exist in git (normalize slashes for Windows)
const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
const normalizedList = list.replace(/\\/g, "/")
const normalizedDir = info.directory.replace(/\\/g, "/")
expect(normalizedList).toContain(normalizedDir)
// Cleanup
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
})
describe("remove edge cases", () => {
test("remove non-existent directory succeeds silently", async () => {
await using tmp = await tmpdir({ git: true })
const ok = await withInstance(tmp.path, () =>
Worktree.remove({ directory: path.join(tmp.path, "does-not-exist") }),
)
expect(ok).toBe(true)
})
test("throws NotGitError for non-git directories", async () => {
await using tmp = await tmpdir()
await expect(withInstance(tmp.path, () => Worktree.remove({ directory: "/tmp/fake" }))).rejects.toThrow(
"WorktreeNotGitError",
)
})
})
})
@@ -181,7 +181,7 @@ test("symlink handling", async () => {
})
})
test("large file handling", async () => {
test("file under size limit handling", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
@@ -196,6 +196,23 @@ test("large file handling", async () => {
})
})
test("large added files are skipped", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/huge.txt`, new Uint8Array(2 * 1024 * 1024 + 1))
expect((await Snapshot.patch(before!)).files).toEqual([])
expect(await Snapshot.diff(before!)).toBe("")
expect(await Snapshot.track()).toBe(before)
},
})
})
test("nested directory revert", async () => {
await using tmp = await bootstrap()
await Instance.provide({
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.3.1",
"version": "1.3.2",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.3.1",
"version": "1.3.2",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.3.1",
"version": "1.3.2",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.3.1",
"version": "1.3.2",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.3.1",
"version": "1.3.2",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.3.1",
"version": "1.3.2",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+134 -7
View File
@@ -1,6 +1,9 @@
#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs/promises"
const model = "opencode/gpt-5.3-codex"
interface PR {
number: number
@@ -50,17 +53,76 @@ async function cleanup() {
} catch {}
}
async function fix(pr: PR, files: string[]) {
function lines(prs: PR[]) {
return prs.map((x) => `- #${x.number}: ${x.title}`).join("\n") || "(none)"
}
async function typecheck() {
console.log(" Running typecheck...")
try {
await $`bun typecheck`.cwd("packages/opencode")
return true
} catch (err) {
console.log(`Typecheck failed: ${err}`)
return false
}
}
async function build() {
console.log(" Running final build smoke check...")
try {
await $`./script/build.ts --single`.cwd("packages/opencode")
return true
} catch (err) {
console.log(`Build failed: ${err}`)
return false
}
}
async function install() {
console.log(" Regenerating bun.lock...")
try {
await fs.rm("bun.lock", { force: true })
await $`bun install`
await $`git add bun.lock`
return true
} catch (err) {
console.log(`Install failed: ${err}`)
return false
}
}
async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: number) {
console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`)
const done = lines(prs.filter((x) => applied.includes(x.number)))
const next = lines(prs.slice(idx + 1))
const prompt = [
`Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`,
`Only touch these files: ${files.join(", ")}.`,
`PR #${pr.number}: ${pr.title}`,
`Start with these conflicted files: ${files.join(", ")}.`,
`Merged PRs on HEAD:\n${done}`,
`Pending PRs after this one (context only):\n${next}`,
"IMPORTANT: The conflict resolution must be consistent with already-merged PRs.",
"Pending PRs are context only; do not introduce their changes unless they are already present on HEAD.",
"Prefer already-merged PRs over the base branch when resolving stacked conflicts.",
"If bun.lock is conflicted, do not hand-merge it. Delete bun.lock and run bun install after the code conflicts are resolved.",
"If a PR already deleted a file/directory, do not re-add it, instead apply changes in the new semantic location.",
"If a PR already changed an import, keep that change.",
"After resolving the conflicts, run `bun typecheck` in `packages/opencode`.",
"If typecheck fails, you may also update any files reported by typecheck.",
"Keep any non-conflict edits narrowly scoped to restoring a valid merged state for the current PR batch.",
"Fix any merge-caused typecheck errors before finishing.",
"Keep the merge in progress, do not abort the merge, and do not create a commit.",
"When done, leave the working tree with no unmerged files.",
"When done, leave the working tree with no unmerged files and a passing typecheck.",
].join("\n")
try {
await $`opencode run -m opencode/gpt-5.3-codex ${prompt}`
await $`opencode run -m ${model} ${prompt}`
} catch (err) {
console.log(` opencode failed: ${err}`)
return false
@@ -72,10 +134,68 @@ async function fix(pr: PR, files: string[]) {
return false
}
if (files.includes("bun.lock") && !(await install())) return false
if (!(await typecheck())) return false
console.log(" Conflicts resolved with opencode")
return true
}
async function smoke(prs: PR[], applied: number[]) {
console.log("\nRunning final smoke check with opencode...")
const done = lines(prs.filter((x) => applied.includes(x.number)))
const prompt = [
"The beta merge batch is complete.",
`Merged PRs on HEAD:\n${done}`,
"Run `bun typecheck` in `packages/opencode`.",
"Run `./script/build.ts --single` in `packages/opencode`.",
"Fix any merge-caused issues until both commands pass.",
"Do not create a commit.",
].join("\n")
try {
await $`opencode run -m ${model} ${prompt}`
} catch (err) {
console.log(`Smoke fix failed: ${err}`)
return false
}
if (!(await typecheck())) {
return false
}
if (!(await build())) {
return false
}
const out = await $`git status --porcelain`.text()
if (!out.trim()) {
console.log("Smoke check passed")
return true
}
try {
await $`git add -A`
await $`git commit -m "Fix beta integration"`
} catch (err) {
console.log(`Failed to commit smoke fixes: ${err}`)
return false
}
if (!(await typecheck())) {
return false
}
if (!(await build())) {
return false
}
console.log("Smoke check passed")
return true
}
async function main() {
console.log("Fetching open PRs with beta label...")
@@ -99,8 +219,8 @@ async function main() {
const applied: number[] = []
const failed: FailedPR[] = []
for (const pr of prs) {
console.log(`\nProcessing PR #${pr.number}: ${pr.title}`)
for (const [idx, pr] of prs.entries()) {
console.log(`\nProcessing PR ${idx + 1}/${prs.length} #${pr.number}: ${pr.title}`)
console.log(" Fetching PR head...")
try {
@@ -119,7 +239,7 @@ async function main() {
const files = await conflicts()
if (files.length > 0) {
console.log(" Failed to merge (conflicts)")
if (!(await fix(pr, files))) {
if (!(await fix(pr, files, prs, applied, idx))) {
await cleanup()
failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" })
await commentOnPR(pr.number, "Merge conflicts with dev branch")
@@ -174,6 +294,13 @@ async function main() {
throw new Error(`${failed.length} PR(s) failed to merge`)
}
if (applied.length > 0) {
const ok = await smoke(prs, applied)
if (!ok) {
throw new Error("Final smoke check failed")
}
}
console.log("\nChecking if beta branch has changes...")
await $`git fetch origin beta`
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bun
const repo = "anomalyco/opencode"
const days = 60
const msg =
"To stay organized issues are automatically closed after 90 days of no activity. If the issue is still relevant please open a new one."
const token = process.env.GITHUB_TOKEN
if (!token) {
console.error("GITHUB_TOKEN environment variable is required")
process.exit(1)
}
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
type Issue = {
number: number
updated_at: string
}
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async function close(num: number) {
const base = `https://api.github.com/repos/${repo}/issues/${num}`
const comment = await fetch(`${base}/comments`, {
method: "POST",
headers,
body: JSON.stringify({ body: msg }),
})
if (!comment.ok) throw new Error(`Failed to comment #${num}: ${comment.status} ${comment.statusText}`)
const patch = await fetch(base, {
method: "PATCH",
headers,
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
})
if (!patch.ok) throw new Error(`Failed to close #${num}: ${patch.status} ${patch.statusText}`)
console.log(`Closed https://github.com/${repo}/issues/${num}`)
}
async function main() {
let page = 1
let closed = 0
while (true) {
const res = await fetch(
`https://api.github.com/repos/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&page=${page}`,
{ headers },
)
if (!res.ok) throw new Error(res.statusText)
const all = (await res.json()) as Issue[]
if (all.length === 0) break
console.log(`Fetched page ${page} ${all.length} issues`)
const stale: number[] = []
for (const i of all) {
const updated = new Date(i.updated_at)
if (updated < cutoff) {
stale.push(i.number)
} else {
console.log(`\nFound fresh issue #${i.number}, stopping`)
if (stale.length > 0) {
for (const num of stale) {
await close(num)
closed++
}
}
console.log(`Closed ${closed} issues total`)
return
}
}
if (stale.length > 0) {
for (const num of stale) {
await close(num)
closed++
}
}
page++
}
console.log(`Closed ${closed} issues total`)
}
main().catch((err) => {
console.error("Error:", err)
process.exit(1)
})
+8 -7
View File
@@ -2,18 +2,19 @@
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { buildNotes, getLatestRelease } from "./changelog"
const output = [`version=${Script.version}`]
if (!Script.preview) {
const previous = await getLatestRelease()
const notes = await buildNotes(previous, "HEAD")
const body = notes.join("\n") || "No notable changes"
await $`opencode run --command changelog`.cwd(process.cwd())
const file = `${process.cwd()}/UPCOMING_CHANGELOG.md`
const body = await Bun.file(file)
.text()
.catch(() => "No notable changes")
const dir = process.env.RUNNER_TEMP ?? "/tmp"
const file = `${dir}/opencode-release-notes.txt`
await Bun.write(file, body)
await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${file}`
const notesFile = `${dir}/opencode-release-notes.txt`
await Bun.write(notesFile, body)
await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${notesFile}`
const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json()
output.push(`release=${release.databaseId}`)
output.push(`tag=${release.tagName}`)
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.3.1",
"version": "1.3.2",
"publisher": "sst-dev",
"repository": {
"type": "git",