Compare commits

...
Author SHA1 Message Date
opencode-agent[bot]andHona 887f319769 fix(desktop): restore compact Windows channel badge (#47016)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 20:19:03 +10:00
24f6cb51c8 fix(core): watch new config files and directories (#46925)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-09-03 15:38:28 +05:30
opencode-agent[bot]andvimtor d2bec894bf fix(app): identify desktop in Console device auth (#47001)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-09-03 11:50:45 +02:00
opencode-agent[bot] d7009156a6 chore: update nix node_modules hashes 2026-09-03 08:56:58 +00:00
4cfca7ab68 fix(app): restore terminal application mouse handling (#45140)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-09-03 16:39:27 +08:00
Luke Parker ae7d9f029d fix(session-ui): preserve configured groups on tool failure (#46998) 2026-09-03 07:30:50 +00:00
Luke Parker 9acdb0be18 fix(session-ui): use standard Used labels for thoughts (#46995) 2026-09-03 07:10:56 +00:00
b42555cf01 fix: reuse current location for directory browsing (#46970)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 16:45:17 +10:00
Luke Parker cfe9f13963 fix(ui): capitalize notices in used group (#46992) 2026-09-03 06:43:01 +00:00
40 changed files with 1542 additions and 394 deletions
+1
View File
@@ -1046,6 +1046,7 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
}
}
+1
View File
@@ -177,6 +177,7 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch"
}
}
@@ -42,7 +42,7 @@ test("changes timeline presets and saves custom thinking details", async ({ page
.toEqual({ placement: "grouped", details: "collapsed" })
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await page.getByRole("button", { name: "Reasoning", exact: true }).click()
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await part.getByRole("button").click()
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
@@ -138,7 +138,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
})
test("keeps failed search calls and their error cards outside the collapsed stack", async ({ page }) => {
test("keeps failed search calls and their error cards inside the collapsed stack", async ({ page }) => {
const parts = [
toolPart(
"prt_error_glob",
@@ -161,9 +161,14 @@ test("keeps failed search calls and their error cards outside the collapsed stac
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = page.locator('[data-timeline-part-id="prt_error_glob"]')
const group = page.locator('[data-component="collapsed-tool-group"]')
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
await expect(glob.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await glob.getByRole("button").click()
await expect(glob).toContainText("Invalid tool input")
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
@@ -177,7 +182,8 @@ test("keeps failed search calls and their error cards outside the collapsed stac
.evaluate((element) => getComputedStyle(element, "::before").display),
)
.toBe("none")
await expect(page.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
await group.locator('[data-timeline-part-id="prt_error_grep"]').getByRole("button").click()
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
"Search timed out after 30 seconds",
)
})
@@ -8,13 +8,11 @@ import {
userMessage,
} from "../performance/timeline-stability/fixture"
test("transitions shell and question through running error outcomes", async ({ page }) => {
test("keeps shell and question failures in their Used group", async ({ page }) => {
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "expanded" } },
},
settings: { timelineDetail: timelinePresets[2].value },
messages: [
userMessage(),
assistantMessage(
@@ -26,13 +24,24 @@ test("transitions shell and question through running error outcomes", async ({ p
),
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })))
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toContainText("exit 1")
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())))
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
partUpdated(
toolPart(
shellID,
"shell",
"completed",
{ command: "exit 1" },
{ output: "Command exited 1", metadata: { exit: 1 } },
),
),
)
await timeline.send(
partUpdated(
@@ -40,17 +49,21 @@ test("transitions shell and question through running error outcomes", async ({ p
),
)
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
await expect(group).toHaveAttribute("data-timeline-part-ids", `${shellID},${questionID}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
const shell = group.locator(`[data-timeline-part-id="${shellID}"]`)
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await shell.locator('[data-slot="collapsible-trigger"]').click()
await expect(shell).toContainText("Command exited 1")
const question = group.locator(`[data-timeline-part-id="${questionID}"]`)
await expect(question).toContainText(/dismissed/i)
})
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
test("keeps a failed patch in Used without losing the surviving file choice", async ({ page }) => {
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { timelineDetail: timelinePresets[2].value },
messages: [
userMessage(),
assistantMessage(
@@ -81,7 +94,9 @@ test("preserves surviving grouped patch state when its first patch fails", async
],
})
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
const file = group.locator('[data-scope="apply-patch"] button')
await expect(file).toBeVisible()
await expect(file).toHaveAttribute("aria-expanded", "false")
@@ -104,18 +119,14 @@ test("preserves surviving grouped patch state when its first patch fails", async
const survivingRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
})
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
await expect(group).toHaveAttribute("data-timeline-part-ids", `${failed},${surviving}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await group.locator(`[data-timeline-part-id="${failed}"] [data-slot="collapsible-trigger"]`).click()
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
await expect
.poll(async () => {
const previous = await failedRow.boundingBox()
const next = await survivingRow.boundingBox()
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
})
.toBeGreaterThanOrEqual(-0.5)
})
test("groups instruction files loaded by the same read", async ({ page }) => {
@@ -53,9 +53,8 @@ test("creates a session in a new project and selects its model", async ({ page }
}),
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: (path) =>
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
findFiles: () => ["NewProject"],
// Listings are requested by absolute path and returned relative to the stable Location.
fileList: (path) => (path === "C:/OpenCode" ? [{ path: "./", type: "directory", ignored: false }] : []),
})
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
@@ -76,12 +75,23 @@ test("creates a session in a new project and selects its model", async ({ page }
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
await expect(picker.getByRole("combobox")).toHaveValue("C:\\OpenCode\\NewProject")
const listing = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/fs/list" && url.searchParams.get("path") === "C:/OpenCode"
})
await picker.getByRole("button", { name: "Parent", exact: true }).click()
expect(new URL((await listing).url()).searchParams.get("location[directory]")).toBe(directory)
const directoryItem = picker.getByRole("treeitem", { name: "NewProject", exact: true })
await expect(directoryItem).toBeVisible()
await directoryItem.click()
const selectFolder = page.getByRole("button", { name: "Select folder" })
await expect(directoryItem).toHaveAttribute("aria-selected", "true")
await expect(picker.getByText("C:\\OpenCode\\NewProject", { exact: true })).toBeVisible()
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
await expect(selectFolder).toBeEnabled()
await selectFolder.click()
await expect(picker).toBeHidden()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="composer"]'))
@@ -50,6 +50,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
if (!connection) return
pickDirectory({
server: connection,
location: ServerConnection.key(connection) === ServerConnection.key(projectServer()) ? location().ref : undefined,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
@@ -1,5 +1,6 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
@@ -15,6 +16,7 @@ export function createProviderConnectionController(options: {
pollInterval?: number
}) {
const language = useLanguage()
const platform = usePlatform()
const serverSDK = useServerSDK()
const data = useData()
const location = () => {
@@ -184,7 +186,14 @@ export function createProviderConnectionController(options: {
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => ({ ok: true as const, authorization: response.data }))
.then((response) => {
if (options.provider() === "opencode" && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
response.data.url = url.href
}
return { ok: true as const, authorization: response.data }
})
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { Ghostty, Terminal } from "ghostty-web"
describe("terminal application mouse reporting", () => {
let container: HTMLDivElement
let terminal: Terminal
let canvas: HTMLCanvasElement
let data: string[]
beforeEach(async () => {
container = document.createElement("div")
document.body.appendChild(container)
terminal = new Terminal({ cols: 80, rows: 24, ghostty: await Ghostty.load() })
terminal.open(container)
terminal.write("\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1015h\x1b[?1006h")
canvas = container.querySelector("canvas")!
data = []
terminal.onData((value) => data.push(value))
})
afterEach(() => {
terminal.dispose()
container.remove()
})
const wheel = (deltaX: number, deltaY: number) => {
const event = new WheelEvent("wheel", { deltaX, deltaY, bubbles: true })
Object.defineProperties(event, { clientX: { value: 40 }, clientY: { value: 30 } })
canvas.dispatchEvent(event)
}
test("reports wheel input with its terminal coordinates instead of arrow keys", () => {
wheel(0, -100)
expect(data).toEqual(["\x1b[<64;6;3M"])
})
test("does not discard small trackpad deltas", () => {
wheel(0, -1)
expect(data).toEqual(["\x1b[<64;6;3M"])
})
test("reports horizontal scrolling", () => {
wheel(100, 0)
expect(data).toEqual(["\x1b[<67;6;3M"])
})
test("distinguishes pointer movement from a left-button drag", () => {
canvas.dispatchEvent(new MouseEvent("mousemove", { clientX: 40, clientY: 30, bubbles: true }))
expect(data).toEqual(["\x1b[<35;6;3M"])
})
test("encodes Alt-modified clicks", () => {
canvas.dispatchEvent(new MouseEvent("mousedown", { altKey: true, clientX: 40, clientY: 30, bubbles: true }))
expect(data).toEqual(["\x1b[<8;6;3M"])
})
test("leaves application clicks available for process selection", () => {
canvas.dispatchEvent(new MouseEvent("mousedown", { clientX: 40, clientY: 30, bubbles: true }))
canvas.dispatchEvent(new MouseEvent("mouseup", { clientX: 40, clientY: 30, bubbles: true }))
expect(data).toEqual(["\x1b[<0;6;3M", "\x1b[<0;6;3m"])
})
test("preserves Shift as the terminal selection override", () => {
canvas.dispatchEvent(new MouseEvent("mousedown", { shiftKey: true, clientX: 40, clientY: 30, bubbles: true }))
expect(data).toEqual([])
})
})
+12 -6
View File
@@ -434,7 +434,7 @@ export function Titlebar(props: {
}}
>
<Show when={!mobile() && (!props.verticalTabs || windows())}>
<ChannelIndicator debugTools={props.debugTools} height={windows() ? minHeight() : undefined} />
<ChannelIndicator debugTools={props.debugTools} />
</Show>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
@@ -753,18 +753,23 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
)
}
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void }; height?: string }) {
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
const platform = usePlatform()
const windows = () => platform.platform === "desktop" && platform.os === "windows"
const classes = () => ({
"px-2 rounded-sm": windows(),
"inline-flex h-4 shrink-0 items-center leading-4 px-1.5 rounded-full": !windows(),
})
const style = () => ({
height: props.height,
"font-size": platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
"font-size": windows() ? undefined : platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
})
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono cursor-pointer [app-region:no-drag]"
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono cursor-pointer [app-region:no-drag]"
classList={classes()}
style={style()}
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
@@ -780,7 +785,8 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
<Show when={label}>
{(value) => (
<div
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono"
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono"
classList={classes()}
style={style()}
>
{value()}
@@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
import { useGlobal } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import type { Path } from "@/runtime/server/types"
import type { LocationRef } from "@opencode-ai/client/promise"
import {
absoluteTreePath,
activeTreeNavigation,
@@ -26,16 +26,19 @@ import {
displayPickerPath,
pickerParent,
pickerRoot,
listPickerDirectory,
pickerRelativePath,
pickerAbsolutePath,
} from "./domain"
import "./dialog.css"
import { Divider } from "@opencode-ai/ui/divider"
import { getFilename } from "@opencode-ai/util/path"
interface DirectoryPickerDialogProps {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
location?: LocationRef
mode?: "directory" | "file"
start?: string
}
@@ -67,32 +70,24 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
let navigation = 0
const [fallbackPath] = createResource(
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
() =>
sdk.api.location
.get()
.then(
(location): Path => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}),
)
.catch(() => undefined),
() => (props.location ? undefined : true),
() => sdk.api.location.get().catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const home = createMemo(() => sync.data.path.home || "")
const location = createMemo(() => {
const current = props.location ?? fallbackPath()
return current ? { directory: current.directory, workspace: current.workspaceID } : undefined
})
const start = createMemo(
() =>
props.start ||
sync.data.path.home ||
props.location?.directory ||
sync.data.path.directory ||
fallbackPath()?.home ||
fallbackPath()?.directory,
)
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const cleaned = cleanPickerInput(value)
const typed = cleaned.replace(/\/+$/, "")
@@ -100,12 +95,14 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const base = pickerRoot(cleaned) || root() || start()
const base = location()?.directory
if (!base) return { query: value, items: directories.slice(0, 5) }
const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base))
if (query === undefined) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
location: location(),
query,
type: "file",
limit: 20,
})
@@ -113,7 +110,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
.catch(() => [])
const results = [
...directories,
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })),
]
return {
query: value,
@@ -132,15 +129,9 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.api.file
.list({ location: { directory: absolute } })
.then((result) =>
result.data.map((entry) => ({
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
type: entry.type,
})),
)
.catch(() => undefined)
const current = location()
if (!current) return Promise.resolve(undefined)
return listPickerDirectory(sdk, current, absolute).catch(() => undefined)
})
listings.set(key, request)
const nodes = await request
@@ -282,7 +273,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
createEffect(() => {
const path = start()
if (!path || root()) return
if (!path || !location() || root()) return
void navigate(path)
})
@@ -16,6 +16,7 @@ import {
currentPickerSuggestions,
createDirectorySearch,
createPriorityTaskQueue,
listPickerDirectory,
displayPickerPath,
pickerParent,
pickerRoot,
@@ -130,44 +131,181 @@ test("scopes file autocomplete to the current browser root", () => {
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
})
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
test("resolves directory autocomplete from the browser root without changing location", async () => {
const calls: unknown[] = []
const location = { directory: "/repo", workspace: "workspace_1" }
const sdk = {
api: {
file: {
find: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] })
find: (input: unknown) => {
calls.push(input)
return Promise.resolve({ location, data: [{ path: "src/components/", type: "directory" }] })
},
list: () => Promise.resolve({ data: [] }),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
let base = "/repo"
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base, location: () => location })
await search("components")
expect(await search("components")).toEqual(["/repo/src/components"])
base = "/repo/src"
await search("components")
expect(await search("components")).toEqual(["/repo/src/components"])
expect(directories).toEqual(["/repo", "/repo/src"])
expect(calls).toEqual([
{ location, query: "components", type: "directory", limit: 50 },
{ location, query: "src/components", type: "directory", limit: 50 },
])
})
test("keeps indexed directory results for servers that support empty search", async () => {
test("lists absolute parents and preloads siblings through a stable workspace", async () => {
const calls: unknown[] = []
const location = { directory: "/repo/current", workspace: "workspace_1" }
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: async (input: { path?: string }) => {
calls.push(input)
return {
location,
data:
input.path === "/repo"
? [
{ path: "./", type: "directory" },
{ path: "../sibling/", type: "directory" },
]
: [{ path: "../sibling/src/", type: "directory" }],
}
},
},
},
} as unknown as Parameters<typeof listPickerDirectory>[0]
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
{ name: "current", absolute: "/repo/current", type: "directory" },
{ name: "sibling", absolute: "/repo/sibling", type: "directory" },
])
expect(await listPickerDirectory(sdk, location, "/repo/sibling")).toEqual([
{ name: "src", absolute: "/repo/sibling/src", type: "directory" },
])
expect(calls).toEqual([
{ location, path: "/repo" },
{ location, path: "/repo/sibling" },
])
})
test("uses listings for typed searches outside the current location", async () => {
const calls: unknown[] = []
const location = { directory: "/repo/current", workspace: "workspace_1" }
const sdk = {
api: {
file: {
find: () => Promise.reject(new Error("outside searches must not change location")),
list: async (input: unknown) => {
calls.push(input)
return { location, data: [{ path: "../sibling/", type: "directory" }] }
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
expect(await search("sib")).toEqual(["/repo/sibling"])
expect(calls).toEqual([{ location, path: "/repo" }])
})
test("keeps literal tilde directory names in server listing and search results", async () => {
const location = { directory: "/repo" }
const sdk = {
api: {
file: {
list: async () => ({ location, data: [{ path: "~/", type: "directory" }] }),
find: async () => ({ location, data: [{ path: "~/nested/", type: "directory" }] }),
},
},
} as unknown as Parameters<typeof listPickerDirectory>[0]
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
{ name: "~", absolute: "/repo/~", type: "directory" },
])
const search = createDirectorySearch({ sdk, home: () => "/home/user", base: () => "/repo", location: () => location })
expect(await search("nested")).toEqual(["/repo/~/nested"])
})
test("discards stale typed results without changing the request location", async () => {
const location = { directory: "/repo" }
const pending = Promise.withResolvers<{
location: typeof location
data: Array<{ path: string; type: "directory" }>
}>()
const calls: unknown[] = []
const sdk = {
api: {
file: {
find: async (input: { query: string }) => {
calls.push(input)
if (input.query === "old") return pending.promise
return { location, data: [{ path: "new/", type: "directory" }] }
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
const stale = search("old")
expect(await search("new")).toEqual(["/repo/new"])
pending.resolve({ location, data: [{ path: "old/", type: "directory" }] })
expect(await stale).toEqual([])
expect(calls).toEqual([
{ location, query: "old", type: "directory", limit: 50 },
{ location, query: "new", type: "directory", limit: 50 },
])
})
test("maps server-native drive and share paths without rebasing the location", async () => {
const calls: unknown[] = []
const sdk = {
api: {
file: {
list: async (input: { location: { directory: string }; path: string }) => {
calls.push(input)
return { location: input.location, data: [{ path: "../sibling/", type: "directory" }] }
},
},
},
} as unknown as Parameters<typeof listPickerDirectory>[0]
const drive = { directory: "C:\\Repo\\Current", workspace: "workspace_1" }
expect(await listPickerDirectory(sdk, drive, "c:/repo")).toEqual([
{ name: "sibling", type: "directory", absolute: "C:/Repo/sibling" },
])
const share = { directory: "\\\\Server\\Share\\Current", workspace: "workspace_2" }
expect(await listPickerDirectory(sdk, share, "//server/share")).toEqual([
{ name: "sibling", type: "directory", absolute: "//Server/Share/sibling" },
])
expect(calls).toEqual([
{ location: drive, path: "c:/repo" },
{ location: share, path: "//server/share" },
])
})
test("keeps indexed directory results for servers that support empty search", async () => {
const location = { directory: "/home/luke" }
const sdk = {
api: {
file: {
find: () => Promise.resolve({ location, data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const search = createDirectorySearch({
sdk,
home: () => "/home/luke",
base: () => "/home/luke",
location: () => location,
})
expect(await search("")).toEqual(["/home/luke/projects"])
})
test("lists the default directory when empty search is unsupported", async () => {
const location = { directory: "/home/luke" }
const calls: string[] = []
const directories = Array.from({ length: 60 }, (_, index) => ({
path: `project-${index}/`,
@@ -180,13 +318,19 @@ test("lists the default directory when empty search is unsupported", async () =>
list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "")
return Promise.resolve({
location,
data: [...directories, { path: "README.md", type: "file" }],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const search = createDirectorySearch({
sdk,
home: () => "/home/luke",
base: () => "/home/luke",
location: () => location,
})
const results = await search("")
expect(results).toHaveLength(60)
@@ -195,12 +339,14 @@ test("lists the default directory when empty search is unsupported", async () =>
})
test("matches the default directory listing when typed search is unsupported", async () => {
const location = { directory: "/home/luke" }
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: () =>
Promise.resolve({
location,
data: [
{ path: "Documents/", type: "directory" },
{ path: "Downloads/", type: "directory" },
@@ -209,12 +355,18 @@ test("matches the default directory listing when typed search is unsupported", a
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const search = createDirectorySearch({
sdk,
home: () => "/home/luke",
base: () => "/home/luke",
location: () => location,
})
expect(await search("documents")).toEqual(["/home/luke/Documents"])
})
test("searches from an absolute root without a default base", async () => {
const location = { directory: "/" }
const directories: string[] = []
const sdk = {
api: {
@@ -222,6 +374,7 @@ test("searches from an absolute root without a default base", async () => {
list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({
location,
data: [
{ path: "Users/", type: "directory" },
{ path: "tmp/", type: "directory" },
@@ -231,7 +384,7 @@ test("searches from an absolute root without a default base", async () => {
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined, location: () => location })
expect(await search("/")).toEqual(["/Users", "/tmp"])
expect(directories).toEqual(["/"])
+43 -19
View File
@@ -76,6 +76,11 @@ export function pickerFileSearchQuery(root: string, input: string, home: string)
export function pickerAbsoluteInput(input: string, home: string, current: string) {
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
return pickerAbsolutePath(value, current)
}
export function pickerAbsolutePath(input: string, current: string) {
const value = normalizePickerDrive(input)
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
return canonicalPickerPath(absolute)
}
@@ -321,7 +326,24 @@ export function displayPickerPath(path: string, input: string, home: string) {
return pickerTilde(value, home) || value
}
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
export async function listPickerDirectory(
sdk: ServerSDK,
location: { directory: string; workspace?: string },
directory: string,
) {
const result = await sdk.api.file.list({ location, path: directory })
return result.data.map((entry) => {
const absolute = pickerAbsolutePath(entry.path, result.location.directory)
return { name: getFilename(absolute), type: entry.type, absolute }
})
}
export function createDirectorySearch(args: {
sdk: ServerSDK
location: () => { directory: string; workspace?: string } | undefined
base: () => string | undefined
home: () => string
}) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
@@ -339,21 +361,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
}
const directories = async (directory: string) => {
const key = trimPickerPath(directory)
const location = args.location()
if (!location) return []
const key = JSON.stringify([location, trimPickerPath(directory)])
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.api.file
.list({ location: { directory: key } })
.then((result) => result.data)
const request = listPickerDirectory(args.sdk, location, directory)
.catch(() => [])
.then((nodes) =>
nodes
.filter((node) => node.type === "directory")
.map((node) => {
const relative = trimPickerPath(normalizePickerDrive(node.path))
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
}),
)
.then((nodes) => nodes.filter((node) => node.type === "directory"))
cache.set(key, request)
return request
}
@@ -369,18 +384,27 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const active = () => token === current
const value = cleanPickerInput(filter)
const input = scoped(value)
if (!input) return [] as string[]
const location = args.location()
if (!input || !location) return [] as string[]
const raw = normalizePickerDrive(value)
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.api.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
const relative = pickerRelativePath(location.directory, input.directory)
const results =
relative === undefined
? []
: await args.sdk.api.file
.find({ location, query: joinPickerPath(relative, query), type: "directory", limit: 50 })
.then((result) =>
result.data
.map((entry) => pickerAbsolutePath(entry.path, result.location.directory))
.filter((path) => treePathWithin(input.directory, path)),
)
.catch(() => [])
if (!active()) return []
if (results.length) {
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
return results.slice(0, 50)
}
const fallback = query
? await match(input.directory, query, 50)
@@ -16,6 +16,7 @@ describe("directoryPickerKind", () => {
test("uses the native picker only for local desktop projects", () => {
expect(directoryPickerKind("desktop", local)).toBe("native")
expect(directoryPickerKind("desktop", remote)).toBe("server")
expect(directoryPickerKind("desktop", { ...local, variant: "wsl", distro: "Ubuntu" })).toBe("server")
expect(directoryPickerKind("web", local)).toBe("server")
})
})
@@ -2,6 +2,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/runtime/server/registry"
import { usePlatform } from "@/runtime/platform/platform"
import { lazy } from "solid-js"
import type { LocationRef } from "@opencode-ai/client/promise"
import { directoryPickerKind } from "./policy"
const DirectoryPickerDialog = lazy(() =>
@@ -10,6 +11,7 @@ const DirectoryPickerDialog = lazy(() =>
type DirectoryPickerInput = {
server: ServerConnection.Any
location?: LocationRef
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
+1 -1
View File
@@ -1559,7 +1559,7 @@ export interface PermissionApi<E = never> {
export type FileListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: RelativePath | undefined
readonly path?: string | undefined
}
export type FileListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileListOperation<E = never> = (input?: FileListInput) => Effect.Effect<FileListOutput, E>
@@ -119,6 +119,28 @@ test("multiple consumers share one source and receive live native and RPC events
await events.connections[0].closed
})
test("a slow consumer does not stall other subscribers", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const slow = shared.subscribe()[Symbol.asyncIterator]()
const fast = shared.subscribe()[Symbol.asyncIterator]()
const connected = [slow.next(), fast.next()]
events.connections[0].push({ type: "server.connected" })
await Promise.all(connected)
events.connections[0].push({ type: "permission.asked", value: 1 })
expect(await fast.next()).toEqual({ done: false, value: { type: "permission.asked", value: 1 } })
events.connections[0].push({ type: "session.updated", value: 2 })
expect(await fast.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } })
expect(await slow.next()).toEqual({ done: false, value: { type: "permission.asked", value: 1 } })
expect(await slow.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } })
await slow.return!()
await fast.return!()
await events.connections[0].closed
})
test("an idle consumer does not stall events for an active consumer", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
+44 -120
View File
@@ -4,7 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import { Context, Effect, FiberMap, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import {
AgentsDirectory,
ClaudeDirectory,
@@ -23,6 +23,8 @@ import { Location } from "./location.js"
import { AbsolutePath } from "./schema.js"
import { ConfigVariable } from "./config/variable.js"
import { ConfigNormalize } from "./config/normalize.js"
import { ConfigDiscovery } from "./config/discovery.js"
import { ConfigWatch } from "./config/watch.js"
import { WellKnown } from "./wellknown.js"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
@@ -83,15 +85,12 @@ export const layer = (options?: Options) =>
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const wellknown = yield* WellKnown.Service
const names = ["opencode.json", "opencode.jsonc"]
const reloadLock = Semaphore.makeUnsafe(1)
const fileTargets = new Set<AbsolutePath>()
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
@@ -177,90 +176,21 @@ export const layer = (options?: Options) =>
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
return [
...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
...(yield* Effect.forEach(ConfigDiscovery.names, (file) => loadFile(path.join(directory, file))).pipe(
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)),
new Directory({ type: "directory", path: directory }),
]
})
const discover = Effect.fn("Config.discover")(function* () {
const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
// Global roots and the walk are compared by canonical path: the same
// directory reached under two spellings (a symlinked checkout, macOS
// /var vs /private/var, OPENCODE_CONFIG_DIR inside the project) must
// classify identically or it enters discovery twice.
const globalRoots = yield* Effect.forEach(
[globalDirectory, globalClaudeDirectory, globalAgentsDirectory],
(item) => fs.resolve(item),
)
const locationIsGlobal = (yield* fs.resolve(location.directory)) === globalRoots[0]
const discovered =
locationIsGlobal || options?.project === false
? []
: yield* fs
.up({
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
start: location.directory,
})
.pipe(
Effect.flatMap((items) =>
Effect.forEach(items, (item) =>
fs.resolve(item).pipe(Effect.map((resolved) => ({ item, resolved }))),
),
),
Effect.orDie,
)
const globalEnabled = options?.global !== false
// A walked path that resolves into a global root is global config
// however the walk reached it (home above the project, or a location
// beneath the global config dir), so global: false excludes it
// uniformly — classified once here, not per consumer below. With
// global enabled, the roots themselves and the global config files are
// already loaded below, so the walk must not add them a second time.
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
const visible = discovered
.filter(({ resolved }) =>
globalEnabled
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
)
.map(({ item }) => item)
// We load certain files from a few other folders in the ecosystem
const claude = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
const projectDirectories = visible
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory))
const directPaths = visible
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
.toReversed()
fileTargets.clear()
directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath)))
const direct = yield* Effect.forEach(directPaths, (filepath) => loadFile(filepath)).pipe(
const load = Effect.fn("Config.load")(function* (sources: ConfigDiscovery.Sources) {
const direct = yield* Effect.forEach(sources.direct, (filepath) => loadFile(filepath)).pipe(
Effect.orDie,
Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
)
const file = options?.file
if (file) fileTargets.add(AbsolutePath.make(path.resolve(file)))
const explicit = file
? yield* loadFile(path.resolve(file)).pipe(
const explicit = sources.explicit
? yield* loadFile(sources.explicit).pipe(
Effect.map((config) => (config ? [config] : [])),
Effect.orDie,
)
@@ -281,15 +211,18 @@ export const layer = (options?: Options) =>
// Global entries sit below explicit and direct files; project
// directories rank above them.
const globalSupplementary = globalEnabled ? yield* loadDirectory(globalDirectory).pipe(Effect.orDie) : []
const projectSupplementary = yield* Effect.forEach(projectDirectories, loadDirectory).pipe(
const globalSupplementary = sources.global ? yield* loadDirectory(sources.global).pipe(Effect.orDie) : []
const projectSupplementary = yield* Effect.forEach(
sources.project.filter((root) => root.present),
(root) => loadDirectory(root.path),
).pipe(
Effect.orDie,
Effect.map((entries) => entries.flat()),
)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude,
...agents,
...sources.claude.map((path) => new ClaudeDirectory({ type: "claude", path })),
...sources.agents.map((path) => new AgentsDirectory({ type: "agents", path })),
...globalSupplementary,
...explicit,
...direct,
@@ -298,44 +231,35 @@ export const layer = (options?: Options) =>
]
})
const initial = yield* discover()
let configs = initial
const initial = yield* ConfigDiscovery.discover(options)
let configs = yield* load(initial)
const updates = yield* PubSub.unbounded<Watcher.Update>()
// Vendored trees inside config roots (a plugin's node_modules, a nested
// .git) produce event blizzards that can never change discovery output.
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
// Watch-once: roots leave discovery only by deletion, so a stale watch is
// inert, bounded, and dies with this layer — and keeping a deleted root's
// watch alive is exactly what makes its recreation observable.
const watched = new Set<string>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const files = [
...entries.flatMap((entry) => (entry.type === "document" && entry.path ? [entry.path] : [])),
...fileTargets,
]
const targets = [
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
...files
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
for (const target of targets) {
const key = JSON.stringify(target)
if (watched.has(key)) continue
watched.add(key)
const stream = yield* watcher.subscribe(target)
yield* stream.pipe(
Stream.runForEach((update) => PubSub.publish(updates, update)),
Effect.forkScoped({ startImmediately: true }),
)
const reloads = yield* PubSub.sliding<void>(1)
// Readiness rescans recover writes made before a watch attached.
const requestReload = PubSub.publish(reloads, undefined).pipe(Effect.asVoid)
const watched = yield* FiberMap.make<string>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (sources: ConfigDiscovery.Sources) {
const plan = ConfigWatch.plan(sources)
for (const key of Array.from(watched, ([key]) => key)) {
if (!plan.has(key)) yield* FiberMap.remove(watched, key)
}
for (const [key, target] of plan) {
yield* watcher
.subscribe(target, requestReload)
.pipe(
Effect.flatMap(
Stream.runForEach((update) => PubSub.publish(updates, update).pipe(Effect.andThen(requestReload))),
),
FiberMap.run(watched, key, { onlyIfMissing: true, startImmediately: true }),
)
}
})
const reload = Effect.fn("Config.reload")(
function* () {
const next = yield* discover()
yield* reconcile(next)
const sources = yield* ConfigDiscovery.discover(options)
const next = yield* load(sources)
yield* reconcile(sources)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* bus.publish(Event.Updated, {})
@@ -343,12 +267,12 @@ export const layer = (options?: Options) =>
(effect) => reloadLock.withPermit(effect),
)
yield* Stream.fromPubSub(updates).pipe(
// Subscribe eagerly so synchronous watch readiness isn't dropped.
const pendingReloads = yield* PubSub.subscribe(reloads)
yield* Stream.fromSubscription(pendingReloads).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((update) =>
reload().pipe(
Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })),
),
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
@@ -389,7 +313,7 @@ export const layer = (options?: Options) =>
Effect.forever,
Effect.forkScoped({ startImmediately: true }),
)
yield* reconcile(initial)
yield* reloadLock.withPermit(reconcile(initial))
return Service.of({
entries: Effect.fnUntraced(function* () {
+85
View File
@@ -0,0 +1,85 @@
export * as ConfigDiscovery from "./discovery.js"
import path from "path"
import { Effect } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location.js"
import { AbsolutePath } from "../schema.js"
import type { Options } from "../config.js"
export const names = ["opencode.json", "opencode.jsonc"]
/** Eligible sources in priority order, including paths that may appear later. */
export interface Sources {
readonly global?: AbsolutePath
readonly explicit?: AbsolutePath
readonly direct: readonly AbsolutePath[]
readonly project: readonly { readonly path: AbsolutePath; readonly present: boolean }[]
readonly claude: readonly AbsolutePath[]
readonly agents: readonly AbsolutePath[]
}
export const discover = Effect.fn("ConfigDiscovery.discover")(function* (options?: Options) {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
const globalRoots = yield* Effect.forEach([globalDirectory, globalClaudeDirectory, globalAgentsDirectory], (item) =>
fs.resolve(item),
)
const directories =
(yield* fs.resolve(location.directory)) === globalRoots[0] || options?.project === false
? []
: yield* fs.up({ targets: ["."], start: location.directory }).pipe(Effect.orDie)
const discovered = yield* Effect.forEach(directories, (directory) =>
Effect.gen(function* () {
// Resolve the parent too: missing children must honor symlinked global roots.
const parent = yield* fs.resolve(directory)
const ecosystem = yield* Effect.filter([".claude", ".agents"], (name) => fs.exists(path.join(directory, name)))
return yield* Effect.forEach([...ecosystem, ".opencode", ...names.toReversed()], (name) =>
fs
.resolve(path.join(parent, name))
.pipe(Effect.map((resolved) => ({ item: AbsolutePath.make(path.join(directory, name)), resolved }))),
)
}),
).pipe(
Effect.map((items) => items.flat()),
Effect.orDie,
)
const globalEnabled = options?.global !== false
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
// Global sources must not re-enter through the project walk.
const visible = discovered
.filter(({ resolved }) =>
globalEnabled
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
)
.map(({ item }) => item)
return {
global: globalEnabled ? globalDirectory : undefined,
explicit: options?.file ? AbsolutePath.make(path.resolve(options.file)) : undefined,
direct: visible.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))).toReversed(),
project: yield* Effect.forEach(
visible.filter((item) => path.basename(item) === ".opencode").toReversed(),
(directory) => fs.isDir(directory).pipe(Effect.map((present) => ({ path: directory, present }))),
),
claude: [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
],
agents: [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
],
} satisfies Sources
})
+1 -1
View File
@@ -33,7 +33,7 @@ export const Plugin = define({
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: "file" | "directory") {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe({ path: target, type })
yield* FiberMap.run(
+37
View File
@@ -0,0 +1,37 @@
export * as ConfigWatch from "./watch.js"
import path from "path"
import { FSUtil } from "@opencode-ai/util/fs-util"
import type { Watcher } from "../filesystem/watcher.js"
import type { ConfigDiscovery } from "./discovery.js"
export function plan(sources: ConfigDiscovery.Sources) {
const directories = [
...(sources.global ? [sources.global] : []),
...sources.project.filter((root) => root.present).map((root) => root.path),
]
const files = [
...sources.direct,
...sources.project.map((root) => root.path),
...(sources.explicit ? [sources.explicit] : []),
]
// Keep a parent watch for each root so deletion/recreation is observable.
const parents = Map.groupBy(
files.filter((file) => !directories.some((directory) => file !== directory && FSUtil.contains(directory, file))),
(file) => path.dirname(file),
)
return new Map(
[
...directories.map((path) => ({
path,
type: "directory" as const,
ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"],
})),
...Array.from(parents, ([parent, files]) => ({
path: parent,
type: "entries" as const,
names: [...new Set(files.map((file) => path.basename(file)))].toSorted(),
})),
].map((target) => [JSON.stringify(target), target satisfies Watcher.WatchInput]),
)
}
+7 -6
View File
@@ -25,7 +25,7 @@ export const Content = Schema.Struct({
export type Content = typeof Content.Type
export const ListInput = Schema.Struct({
path: RelativePath.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
@@ -93,17 +93,18 @@ const baseLayer = Layer.effect(
}
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
// Navigation can leave the cwd without activating another Location.
const directory = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(directory).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(target.real).pipe(
return yield* fs.readDirectoryEntries(directory).pipe(
Effect.orDie,
Effect.map((items) =>
items
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = path.join(target.absolute, item.name)
const relative = path.relative(target.directory, absolute)
const absolute = path.join(directory, item.name)
const relative = path.relative(location.directory, absolute) || "."
return [
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
+55 -57
View File
@@ -34,6 +34,7 @@ export type Update = ParcelWatcher.Event
export type WatchInput =
| { readonly path: string; readonly type: "file" }
| { readonly path: string; readonly type: "entries"; readonly names: readonly string[] }
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
export type Subscription = {
@@ -42,25 +43,26 @@ export type Subscription = {
readonly backend?: string
}
type Target = {
readonly target: string
readonly ignore: readonly string[]
} & (
| { readonly type: "entries"; readonly names: readonly string[] }
| { readonly type: "file" | "directory"; readonly names?: readonly string[] }
)
export interface NativeInterface {
/** Starts one OS-level watch, reporting events through `publish` until unsubscribed. */
readonly subscribe: (input: {
readonly type: WatchInput["type"]
readonly target: string
readonly ignore: readonly string[]
readonly publish: (update: Update) => void
}) => Effect.Effect<Subscription | undefined>
readonly subscribe: (
input: Target & { readonly publish: (update: Update) => void },
) => Effect.Effect<Subscription | undefined>
}
/**
* The OS-level watch implementation behind the Watcher service. The default
* layer uses `node:fs.watch` for files and `@parcel/watcher` for directories;
* tests provide implementations they can control.
*/
/** Uses fs.watch for immediate entries and Parcel for recursive directories. */
export class Native extends Context.Service<Native, NativeInterface>()("@opencode/Watcher/Native") {}
export interface Interface {
readonly subscribe: (input: WatchInput) => Effect.Effect<Stream.Stream<Update>>
/** onReady runs after native acquisition and listener registration, when the stream is consumed. */
readonly subscribe: (input: WatchInput, onReady?: Effect.Effect<void>) => Effect.Effect<Stream.Stream<Update>>
}
export const Options = Schema.Struct({
@@ -89,16 +91,13 @@ export const layer = (options?: Options) =>
const native = yield* Native
// Keys compare structurally (effect Equal), so equivalent watches share one entry.
type Key = { readonly type: WatchInput["type"]; readonly target: string; readonly ignore: readonly string[] }
const watchers = yield* RcMap.make({
lookup: (key: Key) =>
lookup: (key: Target) =>
Effect.gen(function* () {
const pubsub = yield* Effect.acquireRelease(PubSub.unbounded<Update>(), (pubsub) => PubSub.shutdown(pubsub))
const subscription = yield* Effect.acquireRelease(
native.subscribe({
type: key.type,
target: key.target,
ignore: key.ignore,
...key,
publish: (update) => PubSub.publishUnsafe(pubsub, update),
}),
(subscription) =>
@@ -127,34 +126,31 @@ export const layer = (options?: Options) =>
}),
})
const subscribe = (input: WatchInput) => {
const subscribe = Effect.fnUntraced(function* (input: WatchInput, onReady: Effect.Effect<void> = Effect.void) {
const target = path.resolve(input.path)
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
return Effect.gen(function* () {
yield* Effect.logInfo("watcher subscribe", {
path: target,
type: input.type,
ignores: ignore.length,
})
return Stream.unwrap(
Effect.gen(function* () {
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore })
return Stream.fromPubSub(pubsub)
}),
)
const names = [...new Set(input.type === "entries" ? input.names : [])].toSorted()
yield* Effect.logInfo("watcher subscribe", {
path: target,
type: input.type,
ignores: ignore.length,
})
}
return Stream.unwrap(
Effect.gen(function* () {
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore, names })
const subscription = yield* PubSub.subscribe(pubsub)
if (yield* PubSub.isShutdown(pubsub)) return Stream.empty
yield* onReady
return Stream.fromSubscription(subscription)
}),
)
})
return Service.of({ subscribe })
}),
)
/**
* Watcher for tests: the real lifecycle over an in-memory Native that records
* acquired watches and routes emitted updates the way the OS watches would: a
* file watch receives updates for its own path, a directory watch receives
* updates for paths inside it that no ignore entry covers.
*/
/** Real subscription lifecycle with in-memory, path-filtered event delivery. */
export const testLayer = Layer.effectContext(
Effect.gen(function* () {
const subscriptions: WatchInput[] = []
@@ -165,21 +161,22 @@ export const testLayer = Layer.effectContext(
subscriptions.push(
input.type === "file"
? { path: input.target, type: "file" }
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
: input.type === "entries"
? { path: input.target, type: "entries", names: input.names }
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
)
// Ignore entries resolve against the target like the parcel wrapper's
// literal paths. Glob entries resolve to paths nothing lives under, so
// they are inert here rather than compiled the way parcel compiles them.
const ignored = input.ignore.map((entry) => path.resolve(input.target, entry))
active.set(
input.publish,
input.type === "file"
? (target) => target === input.target
: (target) =>
FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target)),
)
active.set(input.publish, (target) => {
if (input.type === "file") return target === input.target
if (input.type === "entries")
return path.dirname(target) === input.target && input.names.includes(path.basename(target))
return FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target))
})
return {
unsubscribe: () => {
active.delete(input.publish)
@@ -208,18 +205,19 @@ export const nativeLayer = Layer.succeed(
Native,
Native.of({
subscribe: (input) => {
if (input.type === "file") {
if (input.type === "file" || input.type === "entries") {
return Effect.sync(() => {
const directory = path.dirname(input.target)
const directory = input.type === "file" ? path.dirname(input.target) : input.target
const names = new Set(input.type === "file" ? [path.basename(input.target)] : input.names)
const subscription = watch(directory, { recursive: false }, (_event, file) => {
if (file && path.resolve(directory, file.toString()) !== input.target) return
input.publish({ path: input.target, type: "update" } satisfies Update)
if (file && !names.has(file)) return
for (const name of file ? [file] : names) {
input.publish({ path: path.join(directory, name), type: "update" })
}
})
if ("on" in subscription && typeof subscription.on === "function") {
subscription.on("error", (error: unknown) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: input.target, error })),
)
}
subscription.on("error", (error: unknown) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: directory, error })),
)
return { unsubscribe: () => Promise.resolve(subscription.close()), backend: "node" }
})
}
+51 -8
View File
@@ -165,7 +165,16 @@ describe("Config", () => {
const entries = yield* config.entries()
expect(entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))).toEqual([global])
expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual(["global"])
expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global])
expect(
(yield* watcher.subscriptions())
.filter((subscription) => subscription.type === "directory")
.map((subscription) => subscription.path),
).toEqual([global])
expect(
(yield* watcher.subscriptions()).filter((subscription) =>
subscription.path.includes(`${path.sep}.opencode${path.sep}`),
),
).toEqual([])
})
return Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
@@ -230,6 +239,8 @@ describe("Config", () => {
Effect.gen(function* () {
const config = yield* Config.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
const watcher = yield* Watcher.Test
expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global])
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
@@ -243,17 +254,19 @@ describe("Config", () => {
),
)
it.live("reloads external config and publishes directory updates", () =>
it.live("reloads file substitutions when their source changes", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const file = path.join(global, "opencode.json")
const source = path.join(global, "shell.txt")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
await fs.writeFile(source, "first")
await fs.writeFile(file, JSON.stringify({ shell: "{file:shell.txt}" }))
})
return yield* Effect.gen(function* () {
const config = yield* Config.Service
@@ -264,9 +277,8 @@ describe("Config", () => {
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* watcher.emit({ type: "update", path: path.join(global, "commands", "review.md") })
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
yield* watcher.emit({ type: "update", path: file })
yield* Effect.promise(() => fs.writeFile(source, "second"))
yield* watcher.emit({ type: "update", path: source })
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
@@ -276,6 +288,35 @@ describe("Config", () => {
),
)
it.live("excludes missing files under symlinked global roots when global is disabled", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const link = path.join(tmp.path, "link")
const project = path.join(link, "plugins", "demo")
return Effect.promise(async () => {
await fs.mkdir(path.join(global, "plugins", "demo"), { recursive: true })
await fs.symlink(global, link, process.platform === "win32" ? "junction" : undefined)
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const watcher = yield* Watcher.Test
const subscriptions = yield* watcher.subscriptions()
expect(subscriptions.length).toBeGreaterThan(0)
expect(
subscriptions.filter((item) => inFixture(global, item.path) || inFixture(link, item.path)),
).toEqual([])
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }),
),
),
),
)
}),
),
)
it.live("exposes filesystem updates under config roots through changes", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
@@ -892,6 +933,7 @@ describe("Config", () => {
path: AbsolutePath.make(path.join(tmp.path, "global")),
ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
},
{ type: "entries", path: tmp.path, names: [".opencode", "opencode.json", "opencode.jsonc"] },
])
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
}),
@@ -1447,8 +1489,9 @@ describe("Config", () => {
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
expect(yield* watcher.subscriptions()).toContainEqual({
path: path.join(tmp.path, "opencode.jsonc"),
type: "file",
path: tmp.path,
type: "entries",
names: [".opencode", "opencode.json", "opencode.jsonc"],
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
+165 -1
View File
@@ -1,4 +1,6 @@
import path from "path"
import fs from "fs/promises"
import { writeFileSync } from "node:fs"
import { describe, expect } from "bun:test"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
@@ -20,12 +22,19 @@ import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { Credential } from "@opencode-ai/core/credential"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, Layer, Schema } from "effect"
import { Deferred, Effect, Layer, Schema } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
const it = testEffect(
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))),
@@ -34,6 +43,144 @@ const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
it.live("retains readiness signalled synchronously during initial config startup", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const native = Watcher.Native.of({
subscribe: (input) =>
Effect.sync(() => {
if (input.type === "entries" && input.target === tmp.path) {
// No event is emitted: only synchronous readiness can trigger the reload.
writeFileSync(path.join(tmp.path, "opencode.json"), JSON.stringify({ references: { docs: "./docs" } }))
}
return { unsubscribe: () => Promise.resolve() }
}),
})
return Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigReferencePlugin.Plugin.effect(host)
yield* waitUntil(references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))))
expect((yield* references.list())[0]?.path).toBe(AbsolutePath.make(path.join(tmp.path, "docs")))
}).pipe(Effect.provide(liveConfig(tmp.path, native)))
}),
),
)
it.live("loads the first config written while a new directory watch is starting", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const root = path.join(tmp.path, ".opencode")
const parent = yield* Deferred.make<(update: Watcher.Update) => void>()
const starting = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const native = Watcher.Native.of({
subscribe: (input) =>
Effect.gen(function* () {
if (input.type === "entries" && input.target === tmp.path) {
yield* Deferred.succeed(parent, input.publish)
}
if (input.type === "directory" && input.target === root) {
yield* Deferred.succeed(starting, undefined)
yield* Deferred.await(release)
}
return { unsubscribe: () => Promise.resolve() }
}),
})
return yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigReferencePlugin.Plugin.effect(host)
const publish = yield* Deferred.await(parent)
yield* Effect.promise(() => fs.mkdir(root))
publish({ path: root, type: "create" })
yield* Deferred.await(starting).pipe(Effect.timeout("2 seconds"))
// No file event: the recursive native watch has not been acquired yet.
yield* Effect.promise(() =>
fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ references: { docs: "./docs" } })),
)
yield* Deferred.succeed(release, undefined)
yield* waitUntil(references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))))
expect((yield* references.list())[0]?.path).toBe(AbsolutePath.make(path.join(root, "docs")))
}).pipe(Effect.provide(liveConfig(tmp.path, native)))
}),
),
),
)
for (const input of [
{ file: "opencode.json", empty: false },
{ file: "../opencode.jsonc", empty: false },
{ file: ".opencode/opencode.json", empty: false },
{ file: "../.opencode/opencode.jsonc", empty: true },
]) {
it.live(`loads references when ${input.file} is first created and keeps watching it`, () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const target = path.resolve(project, input.file)
yield* Effect.promise(() => fs.mkdir(project))
return yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigReferencePlugin.Plugin.effect(host)
expect(yield* references.list()).toEqual([])
if (input.empty) {
yield* Effect.promise(() => fs.mkdir(path.dirname(target)))
const config = yield* Config.Service
yield* waitUntil(
config
.entries()
.pipe(
Effect.map((entries) =>
entries.some((entry) => entry.type === "directory" && entry.path === path.dirname(target)),
),
),
)
}
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, JSON.stringify({ references: { docs: "./docs" } }))
})
yield* waitUntil(
references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))),
)
expect((yield* references.list())[0]?.path).toBe(
AbsolutePath.make(path.join(path.dirname(target), "docs")),
)
yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ references: { next: "./next" } })))
yield* waitUntil(
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "next")),
)
yield* Effect.promise(() =>
fs.rm(input.file.includes(".opencode/") ? path.dirname(target) : target, { recursive: true }),
)
yield* waitUntil(references.list().pipe(Effect.map((items) => items.length === 0)))
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, JSON.stringify({ references: { docs: "./docs" } }))
})
yield* waitUntil(
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "docs")),
)
yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ references: { next: "./next" } })))
yield* waitUntil(
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "next")),
)
}).pipe(Effect.provide(liveConfig(project)))
}),
),
),
)
}
it.effect("preserves reference precedence and insertion order across documents", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -123,6 +270,23 @@ describe("config plugin reloads", () => {
)
})
function liveConfig(directory: string, native?: Watcher.NativeInterface) {
return AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node, Reference.node, Global.node, Location.node]), [
Config.node.replace(Config.configured({ global: false })),
Location.node.replace(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
),
Global.node.replace(
Global.layerWith({ config: path.join(directory, "global"), home: path.join(directory, "home") }),
),
Credential.node.replace(emptyCredentialNode),
WellKnown.node.replace(emptyWellknownNode),
...(native
? [Watcher.node.replace(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))]
: []),
])
}
function config(name: string) {
return new Document({
type: "document",
+42
View File
@@ -0,0 +1,42 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import type { ConfigDiscovery } from "@opencode-ai/core/config/discovery"
import { ConfigWatch } from "@opencode-ai/core/config/watch"
import { AbsolutePath } from "@opencode-ai/core/schema"
const project = path.resolve("watch-plan-project")
const root = AbsolutePath.make(path.join(project, ".opencode"))
const sources: ConfigDiscovery.Sources = {
direct: ["opencode.json", "opencode.jsonc"].map((name) => AbsolutePath.make(path.join(project, name))),
project: [{ path: root, present: false }],
claude: [AbsolutePath.make(path.join(project, ".claude"))],
agents: [AbsolutePath.make(path.join(project, ".agents"))],
}
describe("ConfigWatch.plan", () => {
test("groups missing candidates and keeps parent watches when roots appear", () => {
const missing = ConfigWatch.plan(sources)
expect(Array.from(missing.values())).toEqual([
{ path: project, type: "entries", names: [".opencode", "opencode.json", "opencode.jsonc"] },
])
const present = ConfigWatch.plan({ ...sources, project: [{ path: root, present: true }] })
expect(Array.from(present.values())).toEqual([
{ path: root, type: "directory", ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"] },
...missing.values(),
])
})
test("adds exact watches for explicit files only when not already covered", () => {
expect(ConfigWatch.plan({ ...sources, explicit: sources.direct[0] })).toEqual(ConfigWatch.plan(sources))
const present = { ...sources, project: [{ path: root, present: true }] }
expect(ConfigWatch.plan({ ...present, explicit: AbsolutePath.make(path.join(root, "custom.json")) })).toEqual(
ConfigWatch.plan(present),
)
const directory = path.resolve("watch-plan-external")
expect(
Array.from(
ConfigWatch.plan({ ...sources, explicit: AbsolutePath.make(path.join(directory, "custom.json")) }).values(),
),
).toContainEqual({ path: directory, type: "entries", names: ["custom.json"] })
})
})
+137 -16
View File
@@ -53,18 +53,121 @@ function countingNative() {
}
describe("Watcher lifecycle", () => {
it.effect("signals readiness after acquisition and buffers updates published by the ready callback", () =>
Effect.gen(function* () {
const publish = yield* Deferred.make<(update: Watcher.Update) => void>()
const acquired = yield* Deferred.make<void>()
const counts = { ready: 0, closed: 0 }
yield* Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consumer = yield* watcher
.subscribe(
{ path: "/shared", type: "entries", names: ["opencode.json"] },
Effect.gen(function* () {
counts.ready++
const notify = yield* Deferred.await(publish)
notify({ path: "/shared/opencode.json", type: "create" })
}),
)
.pipe(Effect.flatMap(Stream.runHead), Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(publish)
expect(counts.ready).toBe(0)
yield* Deferred.succeed(acquired, undefined)
expect(Option.getOrUndefined(yield* Fiber.join(consumer))).toEqual({
path: "/shared/opencode.json",
type: "create",
})
expect(counts).toEqual({ ready: 1, closed: 1 })
}).pipe(
withNative({
subscribe: (input) =>
Deferred.succeed(publish, input.publish).pipe(
Effect.andThen(Deferred.await(acquired)),
Effect.as({
unsubscribe: async () => {
counts.closed++
},
}),
),
}),
)
}),
)
it.effect("does not signal readiness for an unavailable native watch", () => {
const counts = { ready: 0 }
return Effect.gen(function* () {
const watcher = yield* Watcher.Service
const stream = yield* watcher.subscribe(
{ path: "/unavailable", type: "directory" },
Effect.sync(() => {
counts.ready++
}),
)
yield* Stream.runDrain(stream)
expect(counts.ready).toBe(0)
}).pipe(withNative({ subscribe: () => Effect.undefined }))
})
it.live("watches only named immediate entries, including missing directories", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const native = yield* Watcher.Native
const events: Watcher.Update[] = []
yield* Effect.acquireRelease(
native.subscribe({
type: "entries",
target: tmp.path,
names: ["opencode.json", ".opencode"],
ignore: [],
publish: (update) => events.push(update),
}),
(subscription) => Effect.promise(() => subscription?.unsubscribe() ?? Promise.resolve()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "nested"))
await fs.writeFile(path.join(tmp.path, "nested", "opencode.json"), "ignored")
await fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "ignored")
await fs.writeFile(path.join(tmp.path, "opencode.json"), "first")
})
yield* Effect.sync(() => events.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("1 second"),
)
expect(events.every((update) => update.path === path.join(tmp.path, "opencode.json"))).toBe(true)
yield* Effect.sleep("10 millis")
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".opencode")))
yield* Effect.sync(() => events.some((update) => update.path === path.join(tmp.path, ".opencode"))).pipe(
Effect.filterOrFail(Boolean),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("1 second"),
)
}).pipe(Effect.provide(Watcher.nativeLayer)),
),
),
)
it.effect("interrupting a consumer interrupts a pending acquisition", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const counts = { ready: 0 }
yield* Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consumer = yield* watcher
.subscribe({ path: "/pending", type: "directory" })
.subscribe(
{ path: "/pending", type: "directory" },
Effect.sync(() => {
counts.ready++
}),
)
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(started)
yield* Fiber.interrupt(consumer)
expect(yield* Deferred.isDone(interrupted)).toBe(true)
expect(counts.ready).toBe(0)
}).pipe(
withNative({
subscribe: () =>
@@ -77,16 +180,16 @@ describe("Watcher lifecycle", () => {
}),
)
it.effect("shares one subscription and releases exactly once after the final consumer", () => {
it.effect("shares equivalent entry sets and releases exactly once after the final consumer", () => {
const { native, counts } = countingNative()
return Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consume = () =>
const consume = (names: string[]) =>
watcher
.subscribe({ path: "/shared", type: "directory" })
.subscribe({ path: "/shared", type: "entries", names })
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
const first = yield* consume()
const second = yield* consume()
const first = yield* consume(["opencode.json", ".opencode", "opencode.json"])
const second = yield* consume([".opencode", "opencode.json"])
yield* Effect.yieldNow
expect(counts.subscribes).toBe(1)
@@ -553,17 +656,35 @@ describeNative("LocationWatcher", () => {
})
it.live("publishes .hg/branch events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const started = yield* Deferred.make<string>()
const watcher = Layer.effect(
Watcher.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(branch)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
const service = yield* Watcher.Service
return Watcher.Service.of({
subscribe: (input, onReady) =>
service.subscribe(
input,
Deferred.succeed(started, input.path).pipe(Effect.andThen(onReady ?? Effect.void)),
),
})
}),
{ vcs: "hg" },
),
).pipe(Layer.provide(AppNodeBuilder.build(Watcher.node)))
return yield* withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
// Use the actual acquisition barrier, not a probe write whose event
// callback can race the next write in Bun's filesystem watcher.
expect(yield* Deferred.await(started)).toBe(branch)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
}),
{ vcs: "hg", watcher },
)
}),
)
})
+59 -4
View File
@@ -83,6 +83,34 @@ describe("FileSystem", () => {
),
)
it.live("lists parents and siblings with paths relative to the current location", () =>
withTmp((directory) =>
Effect.gen(function* () {
const current = path.join(directory, "current")
yield* Effect.promise(() => fs.mkdir(current))
yield* Effect.promise(() => fs.mkdir(path.join(directory, "sibling")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "sibling", "file.txt"), "outside"))
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const parent = yield* filesystem.list({ path: RelativePath.make("..") })
expect(parent).toHaveLength(2)
expect(parent.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual(
expect.arrayContaining([
{ path: "." + path.sep, type: "directory" },
{ path: path.join("..", "sibling") + path.sep, type: "directory" },
]),
)
const sibling = yield* filesystem.list({ path: RelativePath.make("../sibling") })
expect(sibling.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
{ path: RelativePath.make(path.join("..", "sibling", "file.txt")), type: "file" },
])
const absolute = yield* filesystem.list({ path: path.join(directory, "sibling") })
expect(absolute).toEqual(sibling)
}).pipe(provide(current))
}),
),
)
it.live("canonicalizes local symlinked directories", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -105,10 +133,37 @@ describe("FileSystem", () => {
it.live("rejects lexical escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(directory)),
const current = path.join(directory, "current")
yield* Effect.promise(() => fs.mkdir(current))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "outside.txt"), "outside"))
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(current))
}),
),
)
it.live("allows listing through an external symlink without allowing file reads", () =>
withTmp((directory) =>
Effect.gen(function* () {
const current = path.join(directory, "current")
const outside = path.join(directory, "outside")
yield* Effect.promise(() => fs.mkdir(current))
yield* Effect.promise(() => fs.mkdir(outside))
yield* Effect.promise(() => fs.writeFile(path.join(outside, "file.txt"), "outside"))
yield* Effect.promise(() => fs.symlink(outside, path.join(current, "link"), "junction"))
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list({ path: RelativePath.make("link") })
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
{ path: RelativePath.make(path.join("link", "file.txt")), type: "file" },
])
const result = yield* filesystem.read({ path: RelativePath.make("link/file.txt") }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(current))
}),
),
)
})
+6 -3
View File
@@ -1,13 +1,15 @@
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "@opencode-ai/schema/location"
import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { PositiveInt } from "@opencode-ai/schema/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const ListQuery = Schema.Struct({
...LocationQuery.fields,
path: RelativePath.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional).annotate({
description: "An absolute path or a path relative to the requested location. Defaults to the location directory.",
}),
})
const FindQuery = Schema.Struct({
@@ -42,7 +44,8 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
OpenApi.annotations({
identifier: "v2.fs.list",
summary: "List directory",
description: "List direct children of one directory relative to the requested location.",
description:
"List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
}),
),
)
@@ -0,0 +1,20 @@
const fs = require("node:fs")
const readline = require("node:readline")
fs.appendFileSync(process.argv[2], `${process.pid}\n`)
readline
.createInterface({ input: process.stdin })
.on("line", (line) => {
const request = JSON.parse(line)
if (request.id === undefined) return
const result =
request.method === "initialize"
? {
protocolVersion: request.params.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: "filesystem-test", version: "1" },
}
: { tools: [] }
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n")
})
.on("close", () => process.exit(0))
+79
View File
@@ -0,0 +1,79 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect, Schedule } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
it.live(
"browsing parents and siblings reuses the current Location and its MCP process",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const current = path.join(tmp.path, "root", "current")
const starts = path.join(tmp.path, "starts")
yield* Effect.promise(async () => {
await fs.mkdir(current, { recursive: true })
await fs.mkdir(path.join(tmp.path, "root", ".git"))
await fs.mkdir(path.join(tmp.path, "root", "sibling", "nested"), { recursive: true })
await fs.writeFile(path.join(tmp.path, "root", "sibling", "file.txt"), "outside")
await fs.writeFile(starts, "")
await fs.writeFile(
path.join(tmp.path, "root", "opencode.json"),
JSON.stringify({
mcp: {
servers: {
filesystem: {
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture", "mcp-starts.cjs"), starts],
},
},
},
}),
)
})
const server = yield* startServer(path.join(tmp.path, "config"))
const list = (directory: string) =>
Effect.promise(async () => {
const url = new URL("/api/fs/list", server.base)
url.searchParams.set("location[directory]", current)
url.searchParams.set("path", directory)
const response = await fetch(url, { headers: server.headers })
expect(response.status).toBe(200)
const result = await response.json()
expect(result.location.directory).toBe(current)
return result.data
})
const loaded = Effect.promise(async () => {
const response = await fetch(new URL("/api/debug/location", server.base), { headers: server.headers })
expect(response.status).toBe(200)
return response.json()
})
const count = Effect.promise(
async () => (await fs.readFile(starts, "utf8")).trim().split("\n").filter(Boolean).length,
)
yield* list(".")
expect(yield* loaded).toEqual([{ directory: current }])
expect(
yield* count.pipe(
Effect.repeat({ while: (n) => n === 0, schedule: Schedule.spaced("25 millis") }),
Effect.timeout("5 seconds"),
),
).toBe(1)
yield* list("..")
const sibling = yield* list("../sibling")
expect(sibling).toEqual([
{ path: path.join("..", "sibling", "nested") + path.sep, type: "directory" },
{ path: path.join("..", "sibling", "file.txt"), type: "file" },
])
expect(yield* list(path.join(tmp.path, "root", "sibling"))).toEqual(sibling)
yield* list("../sibling/nested")
yield* list("../sibling")
expect(yield* loaded).toEqual([{ directory: current }])
expect(yield* count).toBe(1)
}),
15_000,
)
@@ -271,9 +271,7 @@ export namespace Timeline {
detail,
new Set(
messages
.filter(
(message) => timelineNoticeRequired(message) || (message.type === "shell" && shellFailed(message)),
)
.filter((message) => message.type === "compaction" && timelineNoticeRequired(message))
.map((message) => message.id),
),
)
@@ -321,7 +319,8 @@ export namespace Timeline {
const refs = messages.flatMap((message, messageIndex) =>
contentEntries(message)
.filter(
(entry) => isRenderable(entry.content, showReasoning, detail) && !(thinking && entry.content === lastContent),
(entry) =>
isRenderable(entry.content, showReasoning, detail) && !(thinking && entry.content === lastContent),
)
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
)
@@ -662,9 +661,10 @@ function toolGroupType(
detail?: TimelineDetail,
) {
if (detail) {
if (currentToolFailed(content) || content.name === "question") return undefined
if (content.name === "question" && !currentToolFailed(content)) return undefined
const category = timelineCategory(content)!
if (detail[category].placement === "grouped") return "context"
if (currentToolFailed(content)) return undefined
if (content.name === "patch") return "patch"
if (content.name === "edit") return "edit"
return undefined
@@ -71,6 +71,8 @@ export function createSessionTimelineRowRenderer(input: {
input.projection.rows().forEach((row) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => {
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
if (content?.type !== "tool" || content.name !== "patch" || content.state.status === "error") return
const part = `${ref.messageID}:${ref.partID}`
const key = patchGroupKeys.get(part)
if (key && !owners.has(key)) owners.set(key, part)
@@ -273,8 +275,14 @@ export function createSessionTimelineRowRenderer(input: {
function contentDefaultOpen(item: SessionMessageAssistant["content"][number]) {
if (input.timelineDetail) {
if (item.type === "tool" && currentToolFailed(item)) return true
const category = timelineCategory(item)
if (
category &&
input.timelineDetail()[category].placement === "hidden" &&
item.type === "tool" &&
currentToolFailed(item)
)
return true
if (category === "shell" || category === "edit" || category === "thinking")
return input.timelineDetail()[category].details === "expanded"
}
@@ -488,8 +496,8 @@ export function createSessionTimelineRowRenderer(input: {
const value = message()
return (
input.timelineDetail().shell.details === "expanded" ||
value?.status === "timeout" ||
(value?.status === "exited" && value.exit !== undefined && value.exit !== 0)
(input.timelineDetail().shell.placement === "hidden" &&
(value?.status === "timeout" || (value?.status === "exited" && value.exit !== undefined && value.exit !== 0)))
)
})
return (
@@ -529,13 +529,11 @@ export function CurrentContextToolGroup(props: {
)
const label = createMemo(() => {
const notices = props.parts.filter((part) => part.type === "notice").length
if (!names() && !notices) {
const title = i18n.t("ui.messagePart.context.reasoning")
return { text: title, title, before: "", after: "" }
}
const title = [names(), notices ? i18n.plural("ui.messagePart.context.notice", notices) : undefined]
.filter(Boolean)
.join(", ")
const title =
[names(), notices ? i18n.plural("ui.messagePart.context.notice", notices) : undefined]
.filter(Boolean)
.join(", ") ||
i18n.plural("ui.messagePart.context.thought", props.parts.filter((part) => part.type === "reasoning").length)
const text = i18n.t("ui.messagePart.tools.used", { tools: title })
const index = text.indexOf(title)
return { text, title, before: text.slice(0, index).trim(), after: text.slice(index + title.length).trim() }
@@ -25,13 +25,7 @@ import { Skill } from "@opencode-ai/schema/skill"
import { stringWidth } from "../../util/string-width"
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
import { moveSelection, reconcileSelectionWindow, revealSelectionOffset } from "../../ui/select-controller"
import {
directoryAutocompleteExactValue,
directoryAutocompleteMatches,
directoryAutocompleteResultValue,
directoryAutocompleteSearch,
slashArgumentAutocomplete,
} from "../../prompt/directory-completion"
import { directoryAutocomplete, slashArgumentAutocomplete } from "../../prompt/directory-completion"
export type AutocompleteRef = {
onInput: (value: string) => void
@@ -343,20 +337,38 @@ export function Autocomplete(props: {
if (referenceMatch())
return { options: [], failed: false, mode: input.visible, query: input.query, resolved: true }
const { lineRange, base } = parseFileLineRange(input.query ?? "")
const directorySearch =
input.visible === "directory"
? directoryAutocompleteSearch(base, input.location?.directory ?? paths.cwd, paths.home)
: undefined
const requestLocation = {
directory: directorySearch?.directory ?? input.location?.directory,
directory: input.location?.directory,
workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
}
const result = await (
input.visible === "directory"
? client.api.file.list({ location: requestLocation })
: client.api.file.find({ query: base, limit: 20, location: requestLocation })
).then(
const width = props.anchor().width - 4
if (input.visible === "directory") {
const result = await directoryAutocomplete(
client.api.file,
{ ...requestLocation, directory: requestLocation.directory ?? paths.cwd },
base,
paths.home,
).catch(() => undefined)
if (!result)
return info.value?.mode === input.visible
? { ...info.value, failed: true }
: { options: [], failed: true, mode: input.visible, query: input.query, resolved: false }
return {
options: result.map((item) => ({
display: Locale.truncateMiddle(item.value, width),
value: item.value,
isDirectory: true,
path: item.value,
absolute: item.absolute,
onSelect: () => insertDirectory(item.value),
})),
failed: false,
mode: input.visible,
query: input.query,
resolved: true,
}
}
const result = await client.api.file.find({ query: base, limit: 20, location: requestLocation }).then(
(result) => result,
() => undefined,
)
@@ -368,38 +380,8 @@ export function Autocomplete(props: {
const options: AutocompleteOption[] = []
const width = props.anchor().width - 4
const exact = directorySearch ? directoryAutocompleteExactValue(base, directorySearch) : undefined
if (exact) {
options.push({
display: Locale.truncateMiddle(exact, width),
value: exact,
isDirectory: true,
path: exact,
absolute: result.location.directory,
onSelect: () => insertDirectory(exact),
})
}
const entries =
input.visible === "directory"
? result.data.filter(
(item) =>
item.type === "directory" && directoryAutocompleteMatches(item.path, directorySearch?.query ?? ""),
)
: result.data
options.push(
...entries.map((item): AutocompleteOption => {
if (input.visible === "directory") {
const directory = directorySearch ? directoryAutocompleteResultValue(item.path, directorySearch) : item.path
return {
display: Locale.truncateMiddle(directory, width),
value: directory,
isDirectory: true,
path: directory,
absolute: path.resolve(result.location.directory, item.path),
onSelect: () => insertDirectory(directory),
}
}
...result.data.map((item): AutocompleteOption => {
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
return {
display: Locale.truncateMiddle(filename, width),
@@ -1,8 +1,31 @@
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
import type { OpenCodeClient } from "@opencode-ai/client"
import path from "path"
import { displaySlice, promptOffsetWidth } from "./display"
import { parseSlashHead } from "./parse"
export async function directoryAutocomplete(
file: Pick<OpenCodeClient["file"], "list">,
location: { directory: string; workspace?: string },
query: string,
home: string,
) {
const search = directoryAutocompleteSearch(query, location.directory, home)
const result = await file.list({ location, path: search.directory })
const exact = directoryAutocompleteExactValue(query, search)
return [
...(exact ? [{ value: exact, absolute: search.directory }] : []),
...result.data
.filter((item) => item.type === "directory")
.map((item) => path.resolve(result.location.directory, item.path))
.filter((absolute) => directoryAutocompleteMatches(path.basename(absolute), search.query))
.map((absolute) => ({
value: directoryAutocompleteResultValue(path.basename(absolute) + "/", search),
absolute,
})),
]
}
export function slashArgumentAutocomplete(
value: string,
offset: number,
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import path from "path"
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
import {
directoryAutocomplete,
directoryAutocompleteExactValue,
directoryAutocompleteMatches,
directoryAutocompleteResultValue,
@@ -10,6 +11,64 @@ import {
slashArgumentAutocomplete,
} from "../../src/prompt/directory-completion"
describe("directoryAutocomplete", () => {
test("lists parents and siblings through the current workspace without changing location", async () => {
const location = { directory: "/project/current", workspace: "workspace_1" }
const calls: unknown[] = []
const file = {
list: async (input: unknown) => {
calls.push(input)
return {
location: { directory: location.directory, workspaceID: location.workspace },
data: [
{ path: "./", type: "directory" },
{ path: "../sibling/", type: "directory" },
{ path: "../.hidden/", type: "directory" },
{ path: "../README.md", type: "file" },
],
}
},
} as Parameters<typeof directoryAutocomplete>[0]
expect(await directoryAutocomplete(file, location, "..", "/home/user")).toEqual([
{ value: "..", absolute: path.resolve("/project") },
{ value: "../current/", absolute: path.resolve("/project/current") },
{ value: "../sibling/", absolute: path.resolve("/project/sibling") },
])
expect(await directoryAutocomplete(file, location, "../sib", "/home/user")).toEqual([
{ value: "../sibling/", absolute: path.resolve("/project/sibling") },
])
expect(calls).toEqual([
{ location, path: path.resolve("/project") },
{ location, path: path.resolve("/project") },
])
})
test("resolves home and nested sibling completions against the current location", async () => {
const location = { directory: "/project/current", workspace: "workspace_1" }
const calls: unknown[] = []
const file = {
list: async (input: unknown) => {
calls.push(input)
return {
location: { directory: location.directory },
data: [{ path: "../sibling/src/", type: "directory" }],
}
},
} as Parameters<typeof directoryAutocomplete>[0]
expect(await directoryAutocomplete(file, location, "../sibling/s", "/project/sibling")).toEqual([
{ value: "../sibling/src/", absolute: path.resolve("/project/sibling/src") },
])
expect(await directoryAutocomplete(file, location, "~", "/project/sibling")).toEqual([
{ value: "~", absolute: "/project/sibling" },
{ value: "~/src/", absolute: path.resolve("/project/sibling/src") },
])
expect(calls).toEqual([
{ location, path: path.resolve("/project/sibling") },
{ location, path: "/project/sibling" },
])
})
})
const commands = [
{
id: "session.cd",
+4 -3
View File
@@ -108,9 +108,10 @@ const source = {
"ui.messagePart.context.search.other": "{{count}} searches",
"ui.messagePart.context.list.one": "{{count}} list",
"ui.messagePart.context.list.other": "{{count}} lists",
"ui.messagePart.context.notice.one": "{{count}} notice",
"ui.messagePart.context.notice.other": "{{count}} notices",
"ui.messagePart.context.reasoning": "Reasoning",
"ui.messagePart.context.notice.one": "{{count}} Notice",
"ui.messagePart.context.notice.other": "{{count}} Notices",
"ui.messagePart.context.thought.one": "{{count}} Thought",
"ui.messagePart.context.thought.other": "{{count}} Thoughts",
"ui.messagePart.context.match.one": "({{count}} match)",
"ui.messagePart.context.match.other": "({{count}} matches)",
"ui.messagePart.tools.used": "Used {{tools}}",
+193
View File
@@ -0,0 +1,193 @@
--- a/dist/ghostty-web.js
+++ b/dist/ghostty-web.js
@@ -1209,7 +1209,7 @@
*/
getMouseModifiers(A) {
let g = 0;
- return A.shiftKey && (g |= 4), A.metaKey && (g |= 8), A.ctrlKey && (g |= 16), g;
+ return A.shiftKey && (g |= 4), A.altKey && (g |= 8), A.ctrlKey && (g |= 16), g;
}
/**
* Encode mouse event as SGR sequence
@@ -1246,7 +1246,7 @@
*/
handleMouseDown(A) {
var C;
- if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()))
+ if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()) || A.shiftKey)
return;
const g = this.pixelToCell(A);
if (!g)
@@ -1259,7 +1259,7 @@
*/
handleMouseUp(A) {
var C;
- if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()))
+ if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()) || A.shiftKey)
return;
const g = this.pixelToCell(A);
if (!g)
@@ -1272,7 +1272,7 @@
*/
handleMouseMove(A) {
var E, D, i;
- if (this.isDisposed || !((E = this.mouseConfig) != null && E.hasMouseTracking()))
+ if (this.isDisposed || !((E = this.mouseConfig) != null && E.hasMouseTracking()) || A.shiftKey)
return;
const g = ((D = this.getModeCallback) == null ? void 0 : D.call(this, 1002)) ?? !1, I = ((i = this.getModeCallback) == null ? void 0 : i.call(this, 1003)) ?? !1;
if (!g && !I || g && !I && this.mouseButtonsPressed === 0)
@@ -1280,21 +1280,21 @@
const C = this.pixelToCell(A);
if (!C)
return;
- let Q = 32;
- this.mouseButtonsPressed & 1 ? Q += 0 : this.mouseButtonsPressed & 2 ? Q += 1 : this.mouseButtonsPressed & 4 && (Q += 2), this.sendMouseEvent(Q, C.col, C.row, !1, A);
+ let Q = 35;
+ this.mouseButtonsPressed & 1 ? Q = 32 : this.mouseButtonsPressed & 2 ? Q = 33 : this.mouseButtonsPressed & 4 && (Q = 34), this.sendMouseEvent(Q, C.col, C.row, !1, A);
}
/**
* Handle wheel event (scroll)
*/
handleWheel(A) {
- var C;
- if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()))
+ var Q;
+ if (this.isDisposed || !((Q = this.mouseConfig) != null && Q.hasMouseTracking()) || A.shiftKey)
return;
const g = this.pixelToCell(A);
- if (!g)
+ if (!g || A.deltaX === 0 && A.deltaY === 0)
return;
- const I = A.deltaY < 0 ? 64 : 65;
- this.sendMouseEvent(I, g.col, g.row, !1, A), A.preventDefault();
+ const C = Math.abs(A.deltaX) > Math.abs(A.deltaY) ? A.deltaX < 0 ? 66 : 67 : A.deltaY < 0 ? 64 : 65;
+ this.sendMouseEvent(C, g.col, g.row, !1, A), A.preventDefault();
}
/**
* Emit paste data with bracketed paste support
@@ -2353,7 +2353,7 @@
attachEventListeners() {
const A = this.renderer.getCanvas();
A.addEventListener("mousedown", (g) => {
- if (g.button === 0) {
+ if (!(this.wasmTerm.hasMouseTracking() && !g.shiftKey) && g.button === 0) {
A.parentElement && A.parentElement.focus();
const I = this.pixelToCell(g.offsetX, g.offsetY);
this.hasSelection() && this.clearSelection();
@@ -2408,35 +2408,37 @@
}
}
}, document.addEventListener("mouseup", this.boundMouseUpHandler), A.addEventListener("click", (g) => {
- if (g.detail === 2) {
- const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.getWordAtCell(I.col, I.row);
- if (C) {
- const Q = this.viewportRowToAbsolute(I.row);
- this.selectionStart = { col: C.startCol, absoluteRow: Q }, this.selectionEnd = { col: C.endCol, absoluteRow: Q }, this.requestRender();
- const E = this.getSelection();
- E && (this.copyToClipboard(E), this.selectionChangedEmitter.fire());
- }
- } else if (g.detail >= 3) {
- const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.viewportRowToAbsolute(I.row), Q = this.wasmTerm.getScrollbackLength();
- let E = null;
- if (C < Q)
- E = this.wasmTerm.getScrollbackLine(C);
- else {
- const i = C - Q;
- E = this.wasmTerm.getLine(i);
- }
- let D = -1;
- if (E) {
- for (let i = E.length - 1; i >= 0; i--)
- if (E[i] && E[i].codepoint !== 0 && E[i].codepoint !== 32) {
- D = i;
- break;
- }
- }
- if (D >= 0) {
- this.selectionStart = { col: 0, absoluteRow: C }, this.selectionEnd = { col: D, absoluteRow: C }, this.requestRender();
- const i = this.getSelection();
- i && (this.copyToClipboard(i), this.selectionChangedEmitter.fire());
+ if (!(this.wasmTerm.hasMouseTracking() && !g.shiftKey)) {
+ if (g.detail === 2) {
+ const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.getWordAtCell(I.col, I.row);
+ if (C) {
+ const Q = this.viewportRowToAbsolute(I.row);
+ this.selectionStart = { col: C.startCol, absoluteRow: Q }, this.selectionEnd = { col: C.endCol, absoluteRow: Q }, this.requestRender();
+ const E = this.getSelection();
+ E && (this.copyToClipboard(E), this.selectionChangedEmitter.fire());
+ }
+ } else if (g.detail >= 3) {
+ const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.viewportRowToAbsolute(I.row), Q = this.wasmTerm.getScrollbackLength();
+ let E = null;
+ if (C < Q)
+ E = this.wasmTerm.getScrollbackLine(C);
+ else {
+ const i = C - Q;
+ E = this.wasmTerm.getLine(i);
+ }
+ let D = -1;
+ if (E) {
+ for (let i = E.length - 1; i >= 0; i--)
+ if (E[i] && E[i].codepoint !== 0 && E[i].codepoint !== 32) {
+ D = i;
+ break;
+ }
+ }
+ if (D >= 0) {
+ this.selectionStart = { col: 0, absoluteRow: C }, this.selectionEnd = { col: D, absoluteRow: C }, this.requestRender();
+ const i = this.getSelection();
+ i && (this.copyToClipboard(i), this.selectionChangedEmitter.fire());
+ }
}
}
}), this.boundContextMenuHandler = (g) => {
@@ -2676,23 +2678,27 @@
const M = await this.linkDetector.getLinkAt(Q, o);
M && (M.activate(I), (I.ctrlKey || I.metaKey) && I.preventDefault());
}, this.handleWheel = (I) => {
- var Q, E, D;
- if (I.preventDefault(), I.stopPropagation(), this.customWheelEventHandler && this.customWheelEventHandler(I))
+ var Q, E, D, i;
+ if (I.preventDefault(), this.customWheelEventHandler && this.customWheelEventHandler(I)) {
+ I.stopPropagation();
+ return;
+ }
+ if ((Q = this.wasmTerm) != null && Q.hasMouseTracking() && !I.shiftKey)
return;
- if (((Q = this.wasmTerm) == null ? void 0 : Q.isAlternateScreen()) ?? !1) {
- const i = I.deltaY > 0 ? "down" : "up", o = Math.min(Math.abs(Math.round(I.deltaY / 33)), 5);
- for (let s = 0; s < o; s++)
- i === "up" ? this.dataEmitter.fire("\x1B[A") : this.dataEmitter.fire("\x1B[B");
+ if (I.stopPropagation(), ((E = this.wasmTerm) == null ? void 0 : E.isAlternateScreen()) ?? !1) {
+ const o = I.deltaY > 0 ? "down" : "up", s = Math.min(Math.abs(Math.round(I.deltaY / 33)), 5);
+ for (let G = 0; G < s; G++)
+ o === "up" ? this.dataEmitter.fire("\x1B[A") : this.dataEmitter.fire("\x1B[B");
} else {
- let i;
+ let o;
if (I.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
- const o = ((D = (E = this.renderer) == null ? void 0 : E.getMetrics()) == null ? void 0 : D.height) ?? 20;
- i = I.deltaY / o;
+ const s = ((i = (D = this.renderer) == null ? void 0 : D.getMetrics()) == null ? void 0 : i.height) ?? 20;
+ o = I.deltaY / s;
} else
- I.deltaMode === WheelEvent.DOM_DELTA_LINE ? i = I.deltaY : I.deltaMode === WheelEvent.DOM_DELTA_PAGE ? i = I.deltaY * this.rows : i = I.deltaY / 33;
- if (i !== 0) {
- const o = this.viewportY - i;
- this.smoothScrollTo(o);
+ I.deltaMode === WheelEvent.DOM_DELTA_LINE ? o = I.deltaY : I.deltaMode === WheelEvent.DOM_DELTA_PAGE ? o = I.deltaY * this.rows : o = I.deltaY / 33;
+ if (o !== 0) {
+ const s = this.viewportY - o;
+ this.smoothScrollTo(s);
}
}
}, this.handleMouseDown = (I) => {
@@ -3254,7 +3260,7 @@
* Clean up components (called on dispose or error)
*/
cleanupComponents() {
- this.selectionManager && (this.selectionManager.dispose(), this.selectionManager = void 0), this.inputHandler && (this.inputHandler.dispose(), this.inputHandler = void 0), this.renderer && (this.renderer.dispose(), this.renderer = void 0), this.canvas && this.canvas.parentNode && (this.canvas.parentNode.removeChild(this.canvas), this.canvas = void 0), this.textarea && this.textarea.parentNode && (this.textarea.parentNode.removeChild(this.textarea), this.textarea = void 0), this.element && (this.element.removeEventListener("wheel", this.handleWheel), this.element.removeEventListener("mousedown", this.handleMouseDown, { capture: !0 }), this.element.removeEventListener("mousemove", this.handleMouseMove), this.element.removeEventListener("mouseleave", this.handleMouseLeave), this.element.removeEventListener("click", this.handleClick), this.element.removeAttribute("contenteditable"), this.element.removeAttribute("role"), this.element.removeAttribute("aria-label"), this.element.removeAttribute("aria-multiline")), this.isOpen && typeof document < "u" && document.removeEventListener("mouseup", this.handleMouseUp), this.scrollbarHideTimeout && (window.clearTimeout(this.scrollbarHideTimeout), this.scrollbarHideTimeout = void 0), this.linkDetector && (this.linkDetector.dispose(), this.linkDetector = void 0), this.wasmTerm && (this.wasmTerm.free(), this.wasmTerm = void 0), this.ghostty = void 0, this.element = void 0, this.textarea = void 0;
+ this.selectionManager && (this.selectionManager.dispose(), this.selectionManager = void 0), this.inputHandler && (this.inputHandler.dispose(), this.inputHandler = void 0), this.renderer && (this.renderer.dispose(), this.renderer = void 0), this.canvas && this.canvas.parentNode && (this.canvas.parentNode.removeChild(this.canvas), this.canvas = void 0), this.textarea && this.textarea.parentNode && (this.textarea.parentNode.removeChild(this.textarea), this.textarea = void 0), this.element && (this.element.removeEventListener("wheel", this.handleWheel, { capture: !0 }), this.element.removeEventListener("mousedown", this.handleMouseDown, { capture: !0 }), this.element.removeEventListener("mousemove", this.handleMouseMove), this.element.removeEventListener("mouseleave", this.handleMouseLeave), this.element.removeEventListener("click", this.handleClick), this.element.removeAttribute("contenteditable"), this.element.removeAttribute("role"), this.element.removeAttribute("aria-label"), this.element.removeAttribute("aria-multiline")), this.isOpen && typeof document < "u" && document.removeEventListener("mouseup", this.handleMouseUp), this.scrollbarHideTimeout && (window.clearTimeout(this.scrollbarHideTimeout), this.scrollbarHideTimeout = void 0), this.linkDetector && (this.linkDetector.dispose(), this.linkDetector = void 0), this.wasmTerm && (this.wasmTerm.free(), this.wasmTerm = void 0), this.ghostty = void 0, this.element = void 0, this.textarea = void 0;
}
/**
* Assert terminal is open (throw if not)