Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3e17c39e3 | ||
|
|
9436cb575b | ||
|
|
d1686661c0 | ||
|
|
305007aa0c | ||
|
|
a2c28fc8d7 | ||
|
|
ce87121067 | ||
|
|
ecd7854853 | ||
|
|
57b8c62909 | ||
|
|
28dc5de6a8 | ||
|
|
c875a1fc90 | ||
|
|
1721c6efdf | ||
|
|
93592702c3 | ||
|
|
61d3f788b8 | ||
|
|
a3b281b2f3 |
@@ -21,7 +21,12 @@ import {
|
|||||||
import type { createSdk } from "./utils"
|
import type { createSdk } from "./utils"
|
||||||
|
|
||||||
export async function defocus(page: Page) {
|
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) {
|
export async function openPalette(page: Page) {
|
||||||
@@ -68,14 +73,50 @@ export async function toggleSidebar(page: Page) {
|
|||||||
|
|
||||||
export async function openSidebar(page: Page) {
|
export async function openSidebar(page: Page) {
|
||||||
if (!(await isSidebarClosed(page))) return
|
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 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) {
|
export async function closeSidebar(page: Page) {
|
||||||
if (await isSidebarClosed(page)) return
|
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 toggleSidebar(page)
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(main).toHaveClass(/xl:border-l/)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openSettings(page: Page) {
|
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) {
|
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 expect(menuTrigger).toBeVisible()
|
||||||
await menuTrigger.click()
|
await menuTrigger.click()
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
||||||
await expect(menu).toBeVisible()
|
await expect(menu).toBeVisible()
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,57 +11,98 @@ import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
|||||||
|
|
||||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
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 stamp = Date.now()
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
const newTitle = `e2e renamed ${stamp}`
|
const newTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, originalTitle, async (session) => {
|
await withSession(sdk, originalTitle, async (session) => {
|
||||||
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /rename/i)
|
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 expect(input).toBeVisible()
|
||||||
await input.fill(newTitle)
|
await input.fill(newTitle)
|
||||||
await input.press("Enter")
|
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 stamp = Date.now()
|
||||||
const title = `e2e archive test ${stamp}`
|
const title = `e2e archive test ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const sessionEl = page.locator(sessionItemSelector(session.id))
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /archive/i)
|
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 stamp = Date.now()
|
||||||
const title = `e2e delete test ${stamp}`
|
const title = `e2e delete test ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const sessionEl = page.locator(sessionItemSelector(session.id))
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /delete/i)
|
await clickMenuItem(menu, /delete/i)
|
||||||
await confirmDialog(page, /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}`
|
const title = `e2e share test ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const { rightSection, popoverBody } = await openSharePopover(page)
|
const { rightSection, popoverBody } = await openSharePopover(page)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openStatusPopover, defocus } from "../actions"
|
import { openStatusPopover } from "../actions"
|
||||||
|
|
||||||
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
|
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -88,7 +88,7 @@ test("status popover closes when clicking outside", async ({ page, gotoSession }
|
|||||||
const { popoverBody } = await openStatusPopover(page)
|
const { popoverBody } = await openStatusPopover(page)
|
||||||
await expect(popoverBody).toBeVisible()
|
await expect(popoverBody).toBeVisible()
|
||||||
|
|
||||||
await defocus(page)
|
await page.getByRole("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
|
||||||
await expect(popoverBody).toHaveCount(0)
|
await expect(popoverBody).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||||
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||||
import { SerializeAddon } from "@/addons/serialize"
|
import { SerializeAddon } from "@/addons/serialize"
|
||||||
@@ -52,6 +53,7 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Terminal = (props: TerminalProps) => {
|
export const Terminal = (props: TerminalProps) => {
|
||||||
|
const platform = usePlatform()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
@@ -135,6 +137,22 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
focusTerminal()
|
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(() => {
|
onMount(() => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const loaded = await loadGhostty()
|
const loaded = await loadGhostty()
|
||||||
@@ -240,6 +258,9 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
container.addEventListener("pointerdown", handlePointerDown)
|
container.addEventListener("pointerdown", handlePointerDown)
|
||||||
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
|
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
|
||||||
|
|
||||||
|
container.addEventListener("click", handleLinkClick, { capture: true })
|
||||||
|
cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
|
||||||
|
|
||||||
handleTextareaFocus = () => {
|
handleTextareaFocus = () => {
|
||||||
t.options.cursorBlink = true
|
t.options.cursorBlink = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ function createCommentSession(dir: string, id: string | undefined) {
|
|||||||
setFocus((current) => (current?.id === id ? null : current))
|
setFocus((current) => (current?.id === id ? null : current))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
batch(() => {
|
||||||
|
setStore("comments", {})
|
||||||
|
setFocus(null)
|
||||||
|
setActive(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const all = createMemo(() => {
|
const all = createMemo(() => {
|
||||||
const files = Object.keys(store.comments)
|
const files = Object.keys(store.comments)
|
||||||
const items = files.flatMap((file) => store.comments[file] ?? [])
|
const items = files.flatMap((file) => store.comments[file] ?? [])
|
||||||
@@ -82,6 +90,7 @@ function createCommentSession(dir: string, id: string | undefined) {
|
|||||||
all,
|
all,
|
||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
|
clear,
|
||||||
focus: createMemo(() => state.focus),
|
focus: createMemo(() => state.focus),
|
||||||
setFocus,
|
setFocus,
|
||||||
clearFocus: () => setFocus(null),
|
clearFocus: () => setFocus(null),
|
||||||
@@ -144,6 +153,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
|||||||
all: () => session().all(),
|
all: () => session().all(),
|
||||||
add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
|
add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
|
||||||
remove: (file: string, id: string) => session().remove(file, id),
|
remove: (file: string, id: string) => session().remove(file, id),
|
||||||
|
clear: () => session().clear(),
|
||||||
focus: () => session().focus(),
|
focus: () => session().focus(),
|
||||||
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
|
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
|
||||||
clearFocus: () => session().clearFocus(),
|
clearFocus: () => session().clearFocus(),
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { usePermission } from "@/context/permission"
|
|||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { playSound, soundSrc } from "@/utils/sound"
|
import { playSound, soundSrc } from "@/utils/sound"
|
||||||
|
import { createAim } from "@/utils/aim"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { agentColor } from "@/utils/agent"
|
import { agentColor } from "@/utils/agent"
|
||||||
|
|
||||||
@@ -146,9 +147,20 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const navLeave = { current: undefined as number | undefined }
|
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(() => {
|
onCleanup(() => {
|
||||||
if (navLeave.current === undefined) return
|
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||||
clearTimeout(navLeave.current)
|
aim.reset()
|
||||||
})
|
})
|
||||||
|
|
||||||
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
||||||
@@ -162,15 +174,22 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!layout.sidebar.opened()) return
|
if (!layout.sidebar.opened()) return
|
||||||
|
aim.reset()
|
||||||
setState("hoverProject", undefined)
|
setState("hoverProject", undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (state.hoverProject !== undefined) return
|
||||||
|
aim.reset()
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => ({ dir: params.dir, id: params.id }),
|
() => ({ dir: params.dir, id: params.id }),
|
||||||
() => {
|
() => {
|
||||||
if (layout.sidebar.opened()) return
|
if (layout.sidebar.opened()) return
|
||||||
if (!state.hoverProject) return
|
if (!state.hoverProject) return
|
||||||
|
aim.reset()
|
||||||
setState("hoverSession", undefined)
|
setState("hoverSession", undefined)
|
||||||
setState("hoverProject", undefined)
|
setState("hoverProject", undefined)
|
||||||
},
|
},
|
||||||
@@ -2311,17 +2330,17 @@ export default function Layout(props: ParentProps) {
|
|||||||
!selected() && !active(),
|
!selected() && !active(),
|
||||||
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
|
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={(event: MouseEvent) => {
|
||||||
if (!overlay()) return
|
if (!overlay()) return
|
||||||
globalSync.child(props.project.worktree)
|
aim.enter(props.project.worktree, event)
|
||||||
setState("hoverProject", props.project.worktree)
|
}}
|
||||||
setState("hoverSession", undefined)
|
onMouseLeave={() => {
|
||||||
|
if (!overlay()) return
|
||||||
|
aim.leave(props.project.worktree)
|
||||||
}}
|
}}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!overlay()) return
|
if (!overlay()) return
|
||||||
globalSync.child(props.project.worktree)
|
aim.activate(props.project.worktree)
|
||||||
setState("hoverProject", props.project.worktree)
|
|
||||||
setState("hoverSession", undefined)
|
|
||||||
}}
|
}}
|
||||||
onClick={() => navigateToProject(props.project.worktree)}
|
onClick={() => navigateToProject(props.project.worktree)}
|
||||||
onBlur={() => setOpen(false)}
|
onBlur={() => setOpen(false)}
|
||||||
@@ -2806,7 +2825,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-full w-full overflow-hidden">
|
<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">
|
<div class="flex-1 min-h-0 w-full">
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
@@ -2901,6 +2920,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
navLeave.current = undefined
|
navLeave.current = undefined
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
|
aim.reset()
|
||||||
if (!sidebarHovering()) return
|
if (!sidebarHovering()) return
|
||||||
|
|
||||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||||
@@ -2916,7 +2936,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
</div>
|
</div>
|
||||||
<Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
|
<Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
|
||||||
{(project) => (
|
{(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} />
|
<SidebarPanel project={project} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -279,6 +279,10 @@ export default function Page() {
|
|||||||
pendingMessage: undefined as string | undefined,
|
pendingMessage: undefined as string | undefined,
|
||||||
scrollGesture: 0,
|
scrollGesture: 0,
|
||||||
autoCreated: false,
|
autoCreated: false,
|
||||||
|
scroll: {
|
||||||
|
overflow: false,
|
||||||
|
bottom: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -795,6 +799,7 @@ export default function Page() {
|
|||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
let promptDock: HTMLDivElement | undefined
|
let promptDock: HTMLDivElement | undefined
|
||||||
let scroller: HTMLDivElement | undefined
|
let scroller: HTMLDivElement | undefined
|
||||||
|
let content: HTMLDivElement | undefined
|
||||||
|
|
||||||
const scrollGestureWindowMs = 250
|
const scrollGestureWindowMs = 250
|
||||||
|
|
||||||
@@ -1618,10 +1623,40 @@ export default function Page() {
|
|||||||
window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
|
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 = () => {
|
const resumeScroll = () => {
|
||||||
setStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
autoScroll.forceScrollToBottom()
|
autoScroll.forceScrollToBottom()
|
||||||
clearMessageHash()
|
clearMessageHash()
|
||||||
|
|
||||||
|
const el = scroller
|
||||||
|
if (el) scheduleScrollState(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the user returns to the bottom, treat the active message as "latest".
|
// 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) => {
|
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
||||||
scroller = el
|
scroller = el
|
||||||
autoScroll.scrollRef(el)
|
autoScroll.scrollRef(el)
|
||||||
|
if (el) scheduleScrollState(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createResizeObserver(
|
||||||
|
() => content,
|
||||||
|
() => {
|
||||||
|
const el = scroller
|
||||||
|
if (el) scheduleScrollState(el)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const turnInit = 20
|
const turnInit = 20
|
||||||
const turnBatch = 20
|
const turnBatch = 20
|
||||||
let turnHandle: number | undefined
|
let turnHandle: number | undefined
|
||||||
@@ -1759,6 +1803,8 @@ export default function Page() {
|
|||||||
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
|
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)
|
const hash = window.location.hash.slice(1)
|
||||||
if (!hash) {
|
if (!hash) {
|
||||||
autoScroll.forceScrollToBottom()
|
autoScroll.forceScrollToBottom()
|
||||||
|
|
||||||
|
const el = scroller
|
||||||
|
if (el) scheduleScrollState(el)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1864,6 +1913,9 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
autoScroll.forceScrollToBottom()
|
autoScroll.forceScrollToBottom()
|
||||||
|
|
||||||
|
const el = scroller
|
||||||
|
if (el) scheduleScrollState(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
const closestMessage = (node: Element | null): HTMLElement | null => {
|
const closestMessage = (node: Element | null): HTMLElement | null => {
|
||||||
@@ -2029,6 +2081,7 @@ export default function Page() {
|
|||||||
cancelTurnBackfill()
|
cancelTurnBackfill()
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
||||||
|
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2133,8 +2186,9 @@ export default function Page() {
|
|||||||
<div
|
<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"
|
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={{
|
classList={{
|
||||||
"opacity-100 translate-y-0 scale-100": 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": !autoScroll.userScrolled(),
|
"opacity-0 translate-y-2 scale-95 pointer-events-none":
|
||||||
|
!ui.scroll.overflow || ui.scroll.bottom,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -2232,6 +2286,7 @@ export default function Page() {
|
|||||||
markScrollGesture(e.currentTarget)
|
markScrollGesture(e.currentTarget)
|
||||||
}}
|
}}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
|
scheduleScrollState(e.currentTarget)
|
||||||
if (!hasScrollGesture()) return
|
if (!hasScrollGesture()) return
|
||||||
autoScroll.handleScroll()
|
autoScroll.handleScroll()
|
||||||
markScrollGesture(e.currentTarget)
|
markScrollGesture(e.currentTarget)
|
||||||
@@ -2359,7 +2414,13 @@ export default function Page() {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={autoScroll.contentRef}
|
ref={(el) => {
|
||||||
|
content = el
|
||||||
|
autoScroll.contentRef(el)
|
||||||
|
|
||||||
|
const root = scroller
|
||||||
|
if (root) scheduleScrollState(root)
|
||||||
|
}}
|
||||||
role="log"
|
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]"
|
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={{
|
classList={{
|
||||||
@@ -2542,7 +2603,10 @@ export default function Page() {
|
|||||||
}}
|
}}
|
||||||
newSessionWorktree={newSessionWorktree()}
|
newSessionWorktree={newSessionWorktree()}
|
||||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||||
onSubmit={resumeScroll}
|
onSubmit={() => {
|
||||||
|
comments.clear()
|
||||||
|
resumeScroll()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -95,73 +95,7 @@ if (!Script.preview) {
|
|||||||
"",
|
"",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|
||||||
// Source-based PKGBUILD for opencode
|
for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) {
|
||||||
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 (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
try {
|
try {
|
||||||
await $`rm -rf ./dist/aur-${pkg}`
|
await $`rm -rf ./dist/aur-${pkg}`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NamedError } from "@opencode-ai/util/error"
|
|||||||
import { readableStreamToText } from "bun"
|
import { readableStreamToText } from "bun"
|
||||||
import { Lock } from "../util/lock"
|
import { Lock } from "../util/lock"
|
||||||
import { PackageRegistry } from "./registry"
|
import { PackageRegistry } from "./registry"
|
||||||
|
import { proxied } from "@/util/proxied"
|
||||||
|
|
||||||
export namespace BunProc {
|
export namespace BunProc {
|
||||||
const log = Log.create({ service: "bun" })
|
const log = Log.create({ service: "bun" })
|
||||||
@@ -86,20 +87,13 @@ export namespace BunProc {
|
|||||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
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
|
// Build command arguments
|
||||||
const args = [
|
const args = [
|
||||||
"add",
|
"add",
|
||||||
"--force",
|
"--force",
|
||||||
"--exact",
|
"--exact",
|
||||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||||
...(proxied ? ["--no-cache"] : []),
|
...(proxied() ? ["--no-cache"] : []),
|
||||||
"--cwd",
|
"--cwd",
|
||||||
Global.Path.cache,
|
Global.Path.cache,
|
||||||
pkg + "@" + version,
|
pkg + "@" + version,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { Bus } from "@/bus"
|
|||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Event } from "../server/event"
|
import { Event } from "../server/event"
|
||||||
import { PackageRegistry } from "@/bun/registry"
|
import { PackageRegistry } from "@/bun/registry"
|
||||||
|
import { proxied } from "@/util/proxied"
|
||||||
|
|
||||||
export namespace Config {
|
export namespace Config {
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
@@ -247,13 +248,29 @@ export namespace Config {
|
|||||||
const hasGitIgnore = await Bun.file(gitignore).exists()
|
const hasGitIgnore = await Bun.file(gitignore).exists()
|
||||||
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||||
|
|
||||||
await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], {
|
await BunProc.run(
|
||||||
cwd: dir,
|
[
|
||||||
}).catch(() => {})
|
"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
|
// Install any additional dependencies defined in the package.json
|
||||||
// This allows local plugins and custom tools to use external packages
|
// 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) {
|
async function needsInstall(dir: string) {
|
||||||
|
|||||||
@@ -14,6 +14,27 @@ import { iife } from "@/util/iife"
|
|||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { existsSync } from "fs"
|
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 {
|
export namespace Project {
|
||||||
const log = Log.create({ service: "project" })
|
const log = Log.create({ service: "project" })
|
||||||
export const Info = z
|
export const Info = z
|
||||||
@@ -79,18 +100,15 @@ export namespace Project {
|
|||||||
|
|
||||||
// generate id from root commit
|
// generate id from root commit
|
||||||
if (!id) {
|
if (!id) {
|
||||||
const roots = await $`git rev-list --max-parents=0 --all`
|
const roots = await runGit(["rev-list", "--max-parents=0", "--all"], sandbox)
|
||||||
.quiet()
|
.then((output) => {
|
||||||
.nothrow()
|
if (!output) return undefined
|
||||||
.cwd(sandbox)
|
return output
|
||||||
.text()
|
|
||||||
.then((x) =>
|
|
||||||
x
|
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter(Boolean)
|
.filter((line) => line.length > 0)
|
||||||
.map((x) => x.trim())
|
.map((line) => line.trim())
|
||||||
.toSorted(),
|
.toSorted()
|
||||||
)
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|
||||||
if (!roots) {
|
if (!roots) {
|
||||||
@@ -119,12 +137,8 @@ export namespace Project {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const top = await $`git rev-parse --show-toplevel`
|
const top = await runGit(["rev-parse", "--show-toplevel"], sandbox)
|
||||||
.quiet()
|
.then((output) => (output ? path.resolve(sandbox, output.trim()) : undefined))
|
||||||
.nothrow()
|
|
||||||
.cwd(sandbox)
|
|
||||||
.text()
|
|
||||||
.then((x) => path.resolve(sandbox, x.trim()))
|
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|
||||||
if (!top) {
|
if (!top) {
|
||||||
@@ -138,13 +152,10 @@ export namespace Project {
|
|||||||
|
|
||||||
sandbox = top
|
sandbox = top
|
||||||
|
|
||||||
const worktree = await $`git rev-parse --git-common-dir`
|
const worktree = await runGit(["rev-parse", "--git-common-dir"], sandbox)
|
||||||
.quiet()
|
.then((output) => {
|
||||||
.nothrow()
|
if (!output) return undefined
|
||||||
.cwd(sandbox)
|
const dirname = path.dirname(output.trim())
|
||||||
.text()
|
|
||||||
.then((x) => {
|
|
||||||
const dirname = path.dirname(x.trim())
|
|
||||||
if (dirname === ".") return sandbox
|
if (dirname === ".") return sandbox
|
||||||
return dirname
|
return dirname
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) => {
|
"cloudflare-ai-gateway": async (input) => {
|
||||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
||||||
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
|
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
|
||||||
|
|||||||
@@ -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") && !input.model.api.id.includes("gpt-5-chat")) {
|
||||||
if (!input.model.api.id.includes("gpt-5-pro")) {
|
if (!input.model.api.id.includes("gpt-5-pro")) {
|
||||||
result["reasoningEffort"] = "medium"
|
result["reasoningEffort"] = "medium"
|
||||||
|
|||||||
@@ -968,9 +968,11 @@ export namespace SessionPrompt {
|
|||||||
// have to normalize, symbol search returns absolute paths
|
// have to normalize, symbol search returns absolute paths
|
||||||
// Decode the pathname since URL constructor doesn't automatically decode it
|
// Decode the pathname since URL constructor doesn't automatically decode it
|
||||||
const filepath = fileURLToPath(part.url)
|
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"
|
part.mime = "application/x-directory"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -989,7 +991,7 @@ export namespace SessionPrompt {
|
|||||||
// workspace/symbol searches, so we'll try to find the
|
// workspace/symbol searches, so we'll try to find the
|
||||||
// symbol in the document to get the full range
|
// symbol in the document to get the full range
|
||||||
if (start === end) {
|
if (start === end) {
|
||||||
const symbols = await LSP.documentSymbol(filePathURI)
|
const symbols = await LSP.documentSymbol(filePathURI).catch(() => [])
|
||||||
for (const symbol of symbols) {
|
for (const symbol of symbols) {
|
||||||
let range: LSP.Range | undefined
|
let range: LSP.Range | undefined
|
||||||
if ("range" in symbol) {
|
if ("range" in symbol) {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function proxied() {
|
||||||
|
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -44,10 +44,11 @@
|
|||||||
|
|
||||||
[data-component="sticky-accordion-header"] {
|
[data-component="sticky-accordion-header"] {
|
||||||
top: 40px;
|
top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
&[data-expanded]::before {
|
[data-component="sticky-accordion-header"][data-expanded]::before,
|
||||||
top: -40px;
|
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
|
||||||
}
|
top: -40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="accordion-trigger"] {
|
[data-slot="accordion-trigger"] {
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
|
|
||||||
[data-slot="session-review-accordion-content"] {
|
[data-slot="session-review-accordion-content"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +213,9 @@
|
|||||||
[data-slot="session-review-diff-wrapper"] {
|
[data-slot="session-review-diff-wrapper"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
z-index: 0;
|
||||||
--line-comment-z: 5;
|
--line-comment-z: 5;
|
||||||
--line-comment-popover-z: 30;
|
--line-comment-popover-z: 30;
|
||||||
|
--line-comment-open-z: 6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,10 +409,11 @@
|
|||||||
|
|
||||||
[data-component="sticky-accordion-header"] {
|
[data-component="sticky-accordion-header"] {
|
||||||
top: var(--sticky-header-height, 0px);
|
top: var(--sticky-header-height, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
&[data-expanded]::before {
|
[data-component="sticky-accordion-header"][data-expanded]::before,
|
||||||
top: calc(-1 * var(--sticky-header-height, 0px));
|
[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"] {
|
[data-slot="session-turn-accordion-trigger-content"] {
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
[data-component="sticky-accordion-header"] {
|
[data-component="sticky-accordion-header"] {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0px;
|
top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
&[data-expanded] {
|
[data-component="sticky-accordion-header"][data-expanded],
|
||||||
z-index: 10;
|
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"] {
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
&::before {
|
[data-component="sticky-accordion-header"][data-expanded]::before,
|
||||||
content: "";
|
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
|
||||||
z-index: -10;
|
content: "";
|
||||||
position: absolute;
|
z-index: -10;
|
||||||
inset: 0;
|
position: absolute;
|
||||||
background-color: var(--background-stronger);
|
inset: 0;
|
||||||
}
|
background-color: var(--background-stronger);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user