Compare commits

...

14 Commits

Author SHA1 Message Date
Aiden Cline
a3e17c39e3 fix: acp hanging issue 2026-02-04 13:50:07 -06:00
Adam
9436cb575b fix(app): safety triangle for sidebar hover (#12179) 2026-02-04 19:25:55 +00:00
Tom
d1686661c0 fix: ensure kimi-for-coding plan has thinking on by default for k2p5 (#12147) 2026-02-04 12:39:22 -06:00
Aiden Cline
305007aa0c fix: cloudflare workers ai provider (#12157) 2026-02-04 18:07:33 +00:00
Aiden Cline
a2c28fc8d7 fix: ensure that plugin installs use --no-cache when using http proxy to prevent random hangs (see bun issue) (#12161) 2026-02-04 12:01:00 -06:00
Adam
ce87121067 fix(app): clear comments on prompt submission (#12148) 2026-02-04 11:19:03 -06:00
Adam
ecd7854853 test(app): fix e2e test action 2026-02-04 11:03:54 -06:00
Adam
57b8c62909 fix(app): terminal hyperlink clicks 2026-02-04 10:54:55 -06:00
Adam
28dc5de6a8 fix(ui): review comments z-index stacking 2026-02-04 10:35:11 -06:00
Adam
c875a1fc90 test(app): fix e2e test action 2026-02-04 10:28:24 -06:00
Adam
1721c6efdf fix(core): session errors when attachment file not found 2026-02-04 10:21:03 -06:00
Adam
93592702c3 test(app): fix dated e2e tests 2026-02-04 10:10:28 -06:00
Adam
61d3f788b8 fix(app): don't show scroll-to-bottom unecessarily 2026-02-04 10:01:00 -06:00
Dax Raad
a3b281b2f3 ci: remove source-based AUR package from publish script
Simplifies the release process by publishing only the binary package to AUR,

eliminating the need to maintain separate source and binary build configurations.
2026-02-04 10:31:21 -05:00
20 changed files with 570 additions and 161 deletions

View File

@@ -21,7 +21,12 @@ import {
import type { createSdk } from "./utils"
export async function defocus(page: Page) {
await page.mouse.click(5, 5)
await page
.evaluate(() => {
const el = document.activeElement
if (el instanceof HTMLElement) el.blur()
})
.catch(() => undefined)
}
export async function openPalette(page: Page) {
@@ -68,14 +73,50 @@ export async function toggleSidebar(page: Page) {
export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
if (visible) await button.click()
if (!visible) await toggleSidebar(page)
const main = page.locator("main")
const opened = await expect(main)
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) return
await toggleSidebar(page)
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
await expect(main).not.toHaveClass(/xl:border-l/)
}
export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
if (visible) await button.click()
if (!visible) await toggleSidebar(page)
const main = page.locator("main")
const closed = await expect(main)
.toHaveClass(/xl:border-l/, { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closed) return
await toggleSidebar(page)
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await expect(main).toHaveClass(/xl:border-l/)
}
export async function openSettings(page: Page) {
@@ -182,13 +223,30 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
}
export async function openSessionMoreMenu(page: Page, sessionID: string) {
const sessionEl = await hoverSessionItem(page, sessionID)
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first()
const scroller = page.locator(".session-scroller").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page
.locator(dropdownMenuContentSelector)
.filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
.filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
.filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
.first()
const opened = await menu
.isVisible()
.then((x) => x)
.catch(() => false)
if (opened) return menu
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
return menu
}

View File

@@ -11,57 +11,98 @@ import { sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
test("sidebar session can be renamed", async ({ page, sdk, gotoSession }) => {
type Sdk = Parameters<typeof withSession>[0]
async function seedMessage(sdk: Sdk, sessionID: string) {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [{ type: "text", text: "e2e seed" }],
})
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0)
}
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}`
const newTitle = `e2e renamed ${stamp}`
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await openSidebar(page)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(sessionItemSelector(session.id)).locator(inlineInputSelector).first()
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await input.fill(newTitle)
await input.press("Enter")
await expect(page.locator(sessionItemSelector(session.id)).locator("a").first()).toContainText(newTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
})
})
test("sidebar session can be archived", async ({ page, sdk, gotoSession }) => {
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const title = `e2e archive test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await openSidebar(page)
const sessionEl = page.locator(sessionItemSelector(session.id))
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /archive/i)
await expect(sessionEl).not.toBeVisible()
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.time?.archived
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
test("sidebar session can be deleted", async ({ page, sdk, gotoSession }) => {
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const title = `e2e delete test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await openSidebar(page)
const sessionEl = page.locator(sessionItemSelector(session.id))
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /delete/i)
await confirmDialog(page, /delete/i)
await expect(sessionEl).not.toBeVisible()
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID: session.id })
.then((r) => r.data)
.catch(() => undefined)
return data?.id
},
{ timeout: 30_000 },
)
.toBeUndefined()
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
@@ -72,6 +113,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
const title = `e2e share test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const { rightSection, popoverBody } = await openSharePopover(page)

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openStatusPopover, defocus } from "../actions"
import { openStatusPopover } from "../actions"
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
await gotoSession()
@@ -88,7 +88,7 @@ test("status popover closes when clicking outside", async ({ page, gotoSession }
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody).toBeVisible()
await defocus(page)
await page.getByRole("main").click({ position: { x: 5, y: 5 } })
await expect(popoverBody).toHaveCount(0)
})

View File

@@ -1,5 +1,6 @@
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { monoFontFamily, useSettings } from "@/context/settings"
import { SerializeAddon } from "@/addons/serialize"
@@ -52,6 +53,7 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
}
export const Terminal = (props: TerminalProps) => {
const platform = usePlatform()
const sdk = useSDK()
const settings = useSettings()
const theme = useTheme()
@@ -135,6 +137,22 @@ export const Terminal = (props: TerminalProps) => {
focusTerminal()
}
const handleLinkClick = (event: MouseEvent) => {
if (!event.shiftKey && !event.ctrlKey && !event.metaKey) return
if (event.altKey) return
if (event.button !== 0) return
const t = term
if (!t) return
const link = (t as unknown as { currentHoveredLink?: { text: string } }).currentHoveredLink
if (!link?.text) return
event.preventDefault()
event.stopImmediatePropagation()
platform.openLink(link.text)
}
onMount(() => {
const run = async () => {
const loaded = await loadGhostty()
@@ -240,6 +258,9 @@ export const Terminal = (props: TerminalProps) => {
container.addEventListener("pointerdown", handlePointerDown)
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
container.addEventListener("click", handleLinkClick, { capture: true })
cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
handleTextareaFocus = () => {
t.options.cursorBlink = true
}

View File

@@ -70,6 +70,14 @@ function createCommentSession(dir: string, id: string | undefined) {
setFocus((current) => (current?.id === id ? null : current))
}
const clear = () => {
batch(() => {
setStore("comments", {})
setFocus(null)
setActive(null)
})
}
const all = createMemo(() => {
const files = Object.keys(store.comments)
const items = files.flatMap((file) => store.comments[file] ?? [])
@@ -82,6 +90,7 @@ function createCommentSession(dir: string, id: string | undefined) {
all,
add,
remove,
clear,
focus: createMemo(() => state.focus),
setFocus,
clearFocus: () => setFocus(null),
@@ -144,6 +153,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
all: () => session().all(),
add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
remove: (file: string, id: string) => session().remove(file, id),
clear: () => session().clear(),
focus: () => session().focus(),
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
clearFocus: () => session().clearFocus(),

View File

@@ -58,6 +58,7 @@ import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { agentColor } from "@/utils/agent"
@@ -146,9 +147,20 @@ export default function Layout(props: ParentProps) {
const navLeave = { current: undefined as number | undefined }
const aim = createAim({
enabled: () => !layout.sidebar.opened(),
active: () => state.hoverProject,
el: () => state.nav,
onActivate: (directory) => {
globalSync.child(directory)
setState("hoverProject", directory)
setState("hoverSession", undefined)
},
})
onCleanup(() => {
if (navLeave.current === undefined) return
clearTimeout(navLeave.current)
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
aim.reset()
})
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
@@ -162,15 +174,22 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
if (!layout.sidebar.opened()) return
aim.reset()
setState("hoverProject", undefined)
})
createEffect(() => {
if (state.hoverProject !== undefined) return
aim.reset()
})
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
() => {
if (layout.sidebar.opened()) return
if (!state.hoverProject) return
aim.reset()
setState("hoverSession", undefined)
setState("hoverProject", undefined)
},
@@ -2311,17 +2330,17 @@ export default function Layout(props: ParentProps) {
!selected() && !active(),
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
}}
onMouseEnter={() => {
onMouseEnter={(event: MouseEvent) => {
if (!overlay()) return
globalSync.child(props.project.worktree)
setState("hoverProject", props.project.worktree)
setState("hoverSession", undefined)
aim.enter(props.project.worktree, event)
}}
onMouseLeave={() => {
if (!overlay()) return
aim.leave(props.project.worktree)
}}
onFocus={() => {
if (!overlay()) return
globalSync.child(props.project.worktree)
setState("hoverProject", props.project.worktree)
setState("hoverSession", undefined)
aim.activate(props.project.worktree)
}}
onClick={() => navigateToProject(props.project.worktree)}
onBlur={() => setOpen(false)}
@@ -2806,7 +2825,7 @@ export default function Layout(props: ParentProps) {
return (
<div class="flex h-full w-full overflow-hidden">
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden">
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden" onMouseMove={aim.move}>
<div class="flex-1 min-h-0 w-full">
<DragDropProvider
onDragStart={handleDragStart}
@@ -2901,6 +2920,7 @@ export default function Layout(props: ParentProps) {
navLeave.current = undefined
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
@@ -2916,7 +2936,7 @@ export default function Layout(props: ParentProps) {
</div>
<Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
{(project) => (
<div class="absolute inset-y-0 left-16 z-50 flex">
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
<SidebarPanel project={project} />
</div>
)}

View File

@@ -279,6 +279,10 @@ export default function Page() {
pendingMessage: undefined as string | undefined,
scrollGesture: 0,
autoCreated: false,
scroll: {
overflow: false,
bottom: true,
},
})
createEffect(
@@ -795,6 +799,7 @@ export default function Page() {
let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
const scrollGestureWindowMs = 250
@@ -1618,10 +1623,40 @@ export default function Page() {
window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
}
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
const overflow = max > 1
const bottom = !overflow || el.scrollTop >= max - 2
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
}
const scheduleScrollState = (el: HTMLDivElement) => {
scrollStateTarget = el
if (scrollStateFrame !== undefined) return
scrollStateFrame = requestAnimationFrame(() => {
scrollStateFrame = undefined
const target = scrollStateTarget
scrollStateTarget = undefined
if (!target) return
updateScrollState(target)
})
}
const resumeScroll = () => {
setStore("messageId", undefined)
autoScroll.forceScrollToBottom()
clearMessageHash()
const el = scroller
if (el) scheduleScrollState(el)
}
// When the user returns to the bottom, treat the active message as "latest".
@@ -1657,8 +1692,17 @@ export default function Page() {
const setScrollRef = (el: HTMLDivElement | undefined) => {
scroller = el
autoScroll.scrollRef(el)
if (el) scheduleScrollState(el)
}
createResizeObserver(
() => content,
() => {
const el = scroller
if (el) scheduleScrollState(el)
},
)
const turnInit = 20
const turnBatch = 20
let turnHandle: number | undefined
@@ -1759,6 +1803,8 @@ export default function Page() {
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
})
}
if (el) scheduleScrollState(el)
},
)
@@ -1839,6 +1885,9 @@ export default function Page() {
const hash = window.location.hash.slice(1)
if (!hash) {
autoScroll.forceScrollToBottom()
const el = scroller
if (el) scheduleScrollState(el)
return
}
@@ -1864,6 +1913,9 @@ export default function Page() {
}
autoScroll.forceScrollToBottom()
const el = scroller
if (el) scheduleScrollState(el)
}
const closestMessage = (node: Element | null): HTMLElement | null => {
@@ -2029,6 +2081,7 @@ export default function Page() {
cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown)
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
})
return (
@@ -2133,8 +2186,9 @@ export default function Page() {
<div
class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100": autoScroll.userScrolled(),
"opacity-0 translate-y-2 scale-95 pointer-events-none": !autoScroll.userScrolled(),
"opacity-100 translate-y-0 scale-100": ui.scroll.overflow && !ui.scroll.bottom,
"opacity-0 translate-y-2 scale-95 pointer-events-none":
!ui.scroll.overflow || ui.scroll.bottom,
}}
>
<button
@@ -2232,6 +2286,7 @@ export default function Page() {
markScrollGesture(e.currentTarget)
}}
onScroll={(e) => {
scheduleScrollState(e.currentTarget)
if (!hasScrollGesture()) return
autoScroll.handleScroll()
markScrollGesture(e.currentTarget)
@@ -2359,7 +2414,13 @@ export default function Page() {
</Show>
<div
ref={autoScroll.contentRef}
ref={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
role="log"
class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
classList={{
@@ -2542,7 +2603,10 @@ export default function Page() {
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={resumeScroll}
onSubmit={() => {
comments.clear()
resumeScroll()
}}
/>
</Show>
</div>

View File

@@ -0,0 +1,138 @@
type Point = { x: number; y: number }
export function createAim(props: {
enabled: () => boolean
active: () => string | undefined
el: () => HTMLElement | undefined
onActivate: (id: string) => void
delay?: number
max?: number
tolerance?: number
edge?: number
}) {
const state = {
locs: [] as Point[],
timer: undefined as number | undefined,
pending: undefined as string | undefined,
over: undefined as string | undefined,
last: undefined as Point | undefined,
}
const delay = props.delay ?? 250
const max = props.max ?? 4
const tolerance = props.tolerance ?? 80
const edge = props.edge ?? 18
const cancel = () => {
if (state.timer !== undefined) clearTimeout(state.timer)
state.timer = undefined
state.pending = undefined
}
const reset = () => {
cancel()
state.over = undefined
state.last = undefined
state.locs.length = 0
}
const move = (event: MouseEvent) => {
if (!props.enabled()) return
const el = props.el()
if (!el) return
const rect = el.getBoundingClientRect()
const x = event.clientX
const y = event.clientY
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return
state.locs.push({ x, y })
if (state.locs.length > max) state.locs.shift()
}
const wait = () => {
if (!props.enabled()) return 0
if (!props.active()) return 0
const el = props.el()
if (!el) return 0
if (state.locs.length < 2) return 0
const rect = el.getBoundingClientRect()
const loc = state.locs[state.locs.length - 1]
if (!loc) return 0
const prev = state.locs[0] ?? loc
if (prev.x < rect.left || prev.x > rect.right || prev.y < rect.top || prev.y > rect.bottom) return 0
if (state.last && loc.x === state.last.x && loc.y === state.last.y) return 0
if (rect.right - loc.x <= edge) {
state.last = loc
return delay
}
const upper = { x: rect.right, y: rect.top - tolerance }
const lower = { x: rect.right, y: rect.bottom + tolerance }
const slope = (a: Point, b: Point) => (b.y - a.y) / (b.x - a.x)
const decreasing = slope(loc, upper)
const increasing = slope(loc, lower)
const prevDecreasing = slope(prev, upper)
const prevIncreasing = slope(prev, lower)
if (decreasing < prevDecreasing && increasing > prevIncreasing) {
state.last = loc
return delay
}
state.last = undefined
return 0
}
const activate = (id: string) => {
cancel()
props.onActivate(id)
}
const request = (id: string) => {
if (!id) return
if (props.active() === id) return
if (!props.active()) {
activate(id)
return
}
const ms = wait()
if (ms === 0) {
activate(id)
return
}
cancel()
state.pending = id
state.timer = window.setTimeout(() => {
state.timer = undefined
if (state.pending !== id) return
state.pending = undefined
if (!props.enabled()) return
if (!props.active()) return
if (state.over !== id) return
props.onActivate(id)
}, ms)
}
const enter = (id: string, event: MouseEvent) => {
if (!props.enabled()) return
state.over = id
move(event)
request(id)
}
const leave = (id: string) => {
if (state.over === id) state.over = undefined
if (state.pending === id) cancel()
}
return { move, enter, leave, activate, request, cancel, reset }
}

View File

@@ -95,73 +95,7 @@ if (!Script.preview) {
"",
].join("\n")
// Source-based PKGBUILD for opencode
const sourcePkgbuild = [
"# Maintainer: dax",
"# Maintainer: adam",
"",
"pkgname='opencode'",
`pkgver=${pkgver}`,
`_subver=${_subver}`,
"options=('!debug' '!strip')",
"pkgrel=1",
"pkgdesc='The AI coding agent built for the terminal.'",
"url='https://github.com/anomalyco/opencode'",
"arch=('aarch64' 'x86_64')",
"license=('MIT')",
"provides=('opencode')",
"conflicts=('opencode-bin')",
"depends=('ripgrep')",
"makedepends=('git' 'bun' 'go')",
"",
`source=("opencode-\${pkgver}.tar.gz::https://github.com/anomalyco/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`,
`sha256sums=('SKIP')`,
"",
"build() {",
` cd "opencode-\${pkgver}"`,
` bun install`,
" cd ./packages/opencode",
` OPENCODE_CHANNEL=latest OPENCODE_VERSION=${pkgver} bun run ./script/build.ts --single`,
"}",
"",
"package() {",
` cd "opencode-\${pkgver}/packages/opencode"`,
' mkdir -p "${pkgdir}/usr/bin"',
' target_arch="x64"',
' case "$CARCH" in',
' x86_64) target_arch="x64" ;;',
' aarch64) target_arch="arm64" ;;',
' *) printf "unsupported architecture: %s\\n" "$CARCH" >&2 ; return 1 ;;',
" esac",
' libc=""',
" if command -v ldd >/dev/null 2>&1; then",
" if ldd --version 2>&1 | grep -qi musl; then",
' libc="-musl"',
" fi",
" fi",
' if [ -z "$libc" ] && ls /lib/ld-musl-* >/dev/null 2>&1; then',
' libc="-musl"',
" fi",
' base=""',
' if [ "$target_arch" = "x64" ]; then',
" if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then",
' base="-baseline"',
" fi",
" fi",
' bin="dist/opencode-linux-${target_arch}${base}${libc}/bin/opencode"',
' if [ ! -f "$bin" ]; then',
' printf "unable to find binary for %s%s%s\\n" "$target_arch" "$base" "$libc" >&2',
" return 1",
" fi",
' install -Dm755 "$bin" "${pkgdir}/usr/bin/opencode"',
"}",
"",
].join("\n")
for (const [pkg, pkgbuild] of [
["opencode-bin", binaryPkgbuild],
["opencode", sourcePkgbuild],
]) {
for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) {
for (let i = 0; i < 30; i++) {
try {
await $`rm -rf ./dist/aur-${pkg}`

View File

@@ -7,6 +7,7 @@ import { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
export namespace BunProc {
const log = Log.create({ service: "bun" })
@@ -86,20 +87,13 @@ export namespace BunProc {
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
}
const proxied = !!(
process.env.HTTP_PROXY ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.https_proxy
)
// Build command arguments
const args = [
"add",
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied ? ["--no-cache"] : []),
...(proxied() ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,

View File

@@ -29,6 +29,7 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -247,13 +248,29 @@ export namespace Config {
const hasGitIgnore = await Bun.file(gitignore).exists()
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], {
cwd: dir,
}).catch(() => {})
await BunProc.run(
[
"add",
`@opencode-ai/plugin@${targetVersion}`,
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() ? ["--no-cache"] : []),
],
{
cwd: dir,
},
).catch(() => {})
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
await BunProc.run(
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() ? ["--no-cache"] : []),
],
{ cwd: dir },
).catch(() => {})
}
async function needsInstall(dir: string) {

View File

@@ -14,6 +14,27 @@ import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
async function runGit(args: string[], cwd: string) {
if (Flag.OPENCODE_CLIENT === "acp") {
const proc = Bun.spawn({
cmd: ["git", ...args],
cwd,
stdout: "pipe",
stderr: "pipe",
})
const [code, stdout] = await Promise.all([proc.exited, new Response(proc.stdout).text()])
if (code !== 0) return undefined
return stdout
}
return $`git ${args}`
.quiet()
.nothrow()
.cwd(cwd)
.text()
.catch(() => undefined)
}
export namespace Project {
const log = Log.create({ service: "project" })
export const Info = z
@@ -79,18 +100,15 @@ export namespace Project {
// generate id from root commit
if (!id) {
const roots = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(sandbox)
.text()
.then((x) =>
x
const roots = await runGit(["rev-list", "--max-parents=0", "--all"], sandbox)
.then((output) => {
if (!output) return undefined
return output
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
.filter((line) => line.length > 0)
.map((line) => line.trim())
.toSorted()
})
.catch(() => undefined)
if (!roots) {
@@ -119,12 +137,8 @@ export namespace Project {
}
}
const top = await $`git rev-parse --show-toplevel`
.quiet()
.nothrow()
.cwd(sandbox)
.text()
.then((x) => path.resolve(sandbox, x.trim()))
const top = await runGit(["rev-parse", "--show-toplevel"], sandbox)
.then((output) => (output ? path.resolve(sandbox, output.trim()) : undefined))
.catch(() => undefined)
if (!top) {
@@ -138,13 +152,10 @@ export namespace Project {
sandbox = top
const worktree = await $`git rev-parse --git-common-dir`
.quiet()
.nothrow()
.cwd(sandbox)
.text()
.then((x) => {
const dirname = path.dirname(x.trim())
const worktree = await runGit(["rev-parse", "--git-common-dir"], sandbox)
.then((output) => {
if (!output) return undefined
const dirname = path.dirname(output.trim())
if (dirname === ".") return sandbox
return dirname
})

View File

@@ -457,6 +457,29 @@ export namespace Provider {
},
}
},
"cloudflare-workers-ai": async (input) => {
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
if (!accountId) return { autoload: false }
const apiKey = await iife(async () => {
const envToken = Env.get("CLOUDFLARE_API_KEY")
if (envToken) return envToken
const auth = await Auth.get(input.id)
if (auth?.type === "api") return auth.key
return undefined
})
return {
autoload: !!apiKey,
options: {
apiKey,
baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`,
},
async getModel(sdk: any, modelID: string) {
return sdk.languageModel(modelID)
},
}
},
"cloudflare-ai-gateway": async (input) => {
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")

View File

@@ -630,6 +630,18 @@ export namespace ProviderTransform {
}
}
// Enable thinking by default for kimi-k2.5/k2p5 models using anthropic SDK
const modelId = input.model.api.id.toLowerCase()
if (
(input.model.api.npm === "@ai-sdk/anthropic" || input.model.api.npm === "@ai-sdk/google-vertex/anthropic") &&
(modelId.includes("k2p5") || modelId.includes("kimi-k2.5") || modelId.includes("kimi-k2p5"))
) {
result["thinking"] = {
type: "enabled",
budgetTokens: Math.min(16_000, Math.floor(input.model.limit.output / 2 - 1)),
}
}
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"

View File

@@ -968,9 +968,11 @@ export namespace SessionPrompt {
// have to normalize, symbol search returns absolute paths
// Decode the pathname since URL constructor doesn't automatically decode it
const filepath = fileURLToPath(part.url)
const stat = await Bun.file(filepath).stat()
const stat = await Bun.file(filepath)
.stat()
.catch(() => undefined)
if (stat.isDirectory()) {
if (stat?.isDirectory()) {
part.mime = "application/x-directory"
}
@@ -989,7 +991,7 @@ export namespace SessionPrompt {
// workspace/symbol searches, so we'll try to find the
// symbol in the document to get the full range
if (start === end) {
const symbols = await LSP.documentSymbol(filePathURI)
const symbols = await LSP.documentSymbol(filePathURI).catch(() => [])
for (const symbol of symbols) {
let range: LSP.Range | undefined
if ("range" in symbol) {

View File

@@ -0,0 +1,3 @@
export function proxied() {
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
}

View File

@@ -0,0 +1,53 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { tmpdir } from "../fixture/fixture"
describe("session.prompt missing file", () => {
test("does not fail the prompt when a file part is missing", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
await Session.remove(session.id)
},
})
})
})

View File

@@ -44,10 +44,11 @@
[data-component="sticky-accordion-header"] {
top: 40px;
}
&[data-expanded]::before {
top: -40px;
}
[data-component="sticky-accordion-header"][data-expanded]::before,
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
top: -40px;
}
[data-slot="accordion-trigger"] {
@@ -79,6 +80,7 @@
[data-slot="session-review-accordion-content"] {
position: relative;
z-index: 0;
overflow: hidden;
}
@@ -211,7 +213,9 @@
[data-slot="session-review-diff-wrapper"] {
position: relative;
overflow: hidden;
z-index: 0;
--line-comment-z: 5;
--line-comment-popover-z: 30;
--line-comment-open-z: 6;
}
}

View File

@@ -409,10 +409,11 @@
[data-component="sticky-accordion-header"] {
top: var(--sticky-header-height, 0px);
}
&[data-expanded]::before {
top: calc(-1 * var(--sticky-header-height, 0px));
}
[data-component="sticky-accordion-header"][data-expanded]::before,
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
top: calc(-1 * var(--sticky-header-height, 0px));
}
[data-slot="session-turn-accordion-trigger-content"] {

View File

@@ -1,16 +1,18 @@
[data-component="sticky-accordion-header"] {
position: sticky;
top: 0px;
&[data-expanded] {
z-index: 10;
&::before {
content: "";
z-index: -10;
position: absolute;
inset: 0;
background-color: var(--background-stronger);
}
}
}
[data-component="sticky-accordion-header"][data-expanded],
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"] {
z-index: 10;
}
[data-component="sticky-accordion-header"][data-expanded]::before,
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
content: "";
z-index: -10;
position: absolute;
inset: 0;
background-color: var(--background-stronger);
}