Compare commits

..

3 Commits

Author SHA1 Message Date
Dax Raad 939c2c2411 fix(core): list default agent first 2026-08-13 04:56:23 +00:00
Kit Langton 56973e0ca4 fix(core): reject inherited workspace providers (#42227) 2026-08-12 22:55:32 -04:00
Kit Langton c253d4d311 fix(tui): highlight queued prompts on hover (#42219) 2026-08-12 22:32:30 -04:00
7 changed files with 61 additions and 92 deletions
+4 -1
View File
@@ -122,7 +122,10 @@ const layer = Layer.effect(
return { id: info?.id ?? defaultID, info }
}),
list: Effect.fn("Agent.list")(function* () {
return Array.fromIterable(state.get().agents.values())
const agents = Array.fromIterable(state.get().agents.values())
const selected = selectedDefault()
if (!selected) return agents
return [selected, ...agents.filter((agent) => agent.id !== selected.id)]
}),
})
}),
+16 -36
View File
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Duration, Effect, Layer, Schema, Stream } from "effect"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem.js"
@@ -10,7 +10,6 @@ import { Location } from "../location.js"
import { Ripgrep } from "../ripgrep.js"
import { RelativePath } from "../schema.js"
import { Protected } from "./protected.js"
import { Watcher } from "./watcher.js"
export interface Interface {
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
@@ -28,48 +27,33 @@ export const ripgrepLayer = Layer.effect(
Effect.gen(function* () {
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const watcher = yield* Watcher.Service
const scope = yield* Scope.Scope
const files: string[] = []
const directories = new Set<string>()
const home = Protected.isHome(location.directory)
const scan = ripgrep
yield* ripgrep
.find({
cwd: location.directory,
pattern: "*",
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
onEntry: (entry) =>
Effect.sync(() => {
files.push(entry.path)
const parts = entry.path.split("/")
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
}),
})
.pipe(
Effect.orDie,
Effect.map((entries) => {
const files = entries.map((entry) => entry.path)
return {
files,
directories: new Set(
files.flatMap((file) => {
const parts = file.split("/")
return parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join("/") + path.sep)
}),
),
}
}),
)
const [snapshot, invalidate] = yield* Effect.cachedInvalidateWithTTL(scan, Duration.infinity)
const updates = yield* watcher.subscribe({ path: location.directory, type: "directory" })
yield* updates.pipe(
Stream.runForEach(() => invalidate),
Effect.forkScoped,
)
yield* Effect.yieldNow
yield* snapshot.pipe(Effect.forkScoped)
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
return Service.of({
find: (input) =>
Effect.gen(function* () {
const index = yield* snapshot
const items =
input.type === "file"
? index.files
? files
: input.type === "directory"
? Array.from(index.directories)
: [...index.files, ...index.directories]
? Array.from(directories)
: [...files, ...directories]
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
@@ -163,11 +147,7 @@ export const layer = (options?: Options) =>
)
export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Location.node, Ripgrep.node, Watcher.node],
})
return makeLocationNode({ service: Service, layer: layer(options), deps: [Location.node, Ripgrep.node] })
}
export const node = configured()
+1 -1
View File
@@ -55,7 +55,7 @@ export class RegistryService extends Context.Service<RegistryService, Registry>(
export const registry = (drivers: Readonly<Record<string, Interface>>): Registry => ({
get: (provider) => {
const driver = drivers[provider]
const driver = Object.hasOwn(drivers, provider) ? drivers[provider] : undefined
return driver ? Effect.succeed(driver) : Effect.fail(new ProviderNotFound({ provider }))
},
})
+20
View File
@@ -68,6 +68,26 @@ describe("Agent", () => {
}),
)
it.effect("lists the selected default agent first", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
yield* agent.transform((editor) => {
editor.update(Agent.ID.make("build"), (info) => {
info.mode = "primary"
})
editor.update(Agent.ID.make("reviewer"), (info) => {
info.mode = "primary"
})
editor.update(Agent.ID.make("explore"), (info) => {
info.mode = "subagent"
})
editor.default(Agent.ID.make("reviewer"))
})
expect((yield* agent.list()).map((info) => String(info.id))).toEqual(["reviewer", "build", "explore"])
}),
)
it.effect("rebuilds state when a transform is replaced", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
+4 -53
View File
@@ -1,12 +1,11 @@
import { describe, expect, test } from "bun:test"
import os from "os"
import path from "path"
import { Effect, Layer, PubSub, Stream } from "effect"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Protected } from "@opencode-ai/core/filesystem/protected"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Location } from "@opencode-ai/core/location"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
@@ -34,14 +33,15 @@ describe("FileSystemSearch", () => {
find: (input) =>
Effect.gen(function* () {
observed = input
return [FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })]
if (input.onEntry)
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" }))
return []
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
[Watcher.node, Watcher.testLayer],
])
await Effect.runPromise(
@@ -56,53 +56,4 @@ describe("FileSystemSearch", () => {
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
test("refreshes the ripgrep index after files change", async () => {
const root = AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-refresh"))
let scans = 0
const updates = Effect.runSync(PubSub.unbounded<Watcher.Update>())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[Location.node, Layer.succeed(Location.Service, Location.Service.of(location({ directory: root })))],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: () =>
Effect.sync(() => {
scans++
return [
FileSystem.Entry.make({ path: RelativePath.make("src/old.ts"), type: "file" }),
...(scans > 1
? [FileSystem.Entry.make({ path: RelativePath.make("src/new.ts"), type: "file" })]
: []),
]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
[
Watcher.node,
Layer.succeed(
Watcher.Service,
Watcher.Service.of({ subscribe: () => Effect.succeed(Stream.fromPubSub(updates)) }),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
expect((yield* search.find({ query: "new", type: "file" })).length).toBe(0)
yield* PubSub.publish(
updates,
{ type: "create", path: path.join(root, "src/new.ts") } satisfies Watcher.Update,
)
yield* Effect.sleep("100 millis")
expect((yield* search.find({ query: "new", type: "file" }))[0]?.path).toBe(RelativePath.make("src/new.ts"))
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
})
+12
View File
@@ -48,6 +48,18 @@ beforeEach(() => {
failConnect = false
})
it.effect("rejects unregistered workspace providers", () =>
Effect.gen(function* () {
const registry = WorkspaceDriver.registry({ fake: driver })
for (const provider of ["missing", "constructor", "toString", "__proto__"]) {
expect(yield* registry.get(provider).pipe(Effect.flip)).toEqual(
new WorkspaceDriver.ProviderNotFound({ provider }),
)
}
}),
)
it.effect("persists the workspace lifecycle and reconnects after idle suspension", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
+4 -1
View File
@@ -2089,6 +2089,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
const theme = useTheme("elevated")
const [hover, setHover] = createSignal(false)
const next = createMemo(() => props.prompts[0]?.text.replaceAll("\n", " "))
return (
@@ -2096,6 +2097,8 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
border={["left"]}
borderColor={theme.border.default}
customBorderChars={SplitBorder.customBorderChars}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={props.onOpen}
>
<box
@@ -2104,7 +2107,7 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
backgroundColor={theme.background.default}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
flexDirection="row"
>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>