mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 23:38:23 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 846891a57d | |||
| ed708f9dc2 | |||
| f2408060b7 | |||
| 9fff6e2b4f |
+14
-10
@@ -89,6 +89,8 @@ export interface PublishOptions {
|
||||
readonly id?: Event.ID
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly location?: Location.Ref
|
||||
/** Publishes without Location metadata so every Location-scoped subscriber receives the event. */
|
||||
readonly global?: boolean
|
||||
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
|
||||
readonly commit?: (seq: number) => Effect.Effect<void>
|
||||
}
|
||||
@@ -450,11 +452,12 @@ export function configured(options?: Options) {
|
||||
function publish<D extends Event.Definition>(definition: D, data: Event.Data<D>, options?: PublishOptions) {
|
||||
return Effect.gen(function* () {
|
||||
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
const location =
|
||||
options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined)
|
||||
const location = options?.global
|
||||
? undefined
|
||||
: (options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined))
|
||||
return yield* publishEvent(
|
||||
definition,
|
||||
{
|
||||
@@ -484,11 +487,12 @@ export function configured(options?: Options) {
|
||||
}),
|
||||
)
|
||||
}
|
||||
const location =
|
||||
options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined)
|
||||
const location = options?.global
|
||||
? undefined
|
||||
: (options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined))
|
||||
return {
|
||||
definition,
|
||||
aggregateID,
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as InstructionDiscovery from "./instruction-discovery.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { createPatch } from "diff"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Instructions } from "./instructions/index.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
@@ -81,8 +82,7 @@ export const layer = (options?: Options) =>
|
||||
read: Effect.succeed(value),
|
||||
render: {
|
||||
initial: render,
|
||||
changed: (_previous, current) =>
|
||||
`These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
|
||||
changed: renderUpdate,
|
||||
removed: () => "Previously loaded instructions no longer apply.",
|
||||
},
|
||||
})
|
||||
@@ -120,3 +120,27 @@ export const node = configured()
|
||||
function render(files: ReadonlyArray<File>) {
|
||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||
}
|
||||
|
||||
function renderUpdate(previous: ReadonlyArray<File>, current: ReadonlyArray<File>) {
|
||||
const changes = Instructions.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(file) => file.path,
|
||||
(before, after) => before.content !== after.content,
|
||||
)
|
||||
return [
|
||||
...changes.removed.map((file) => `The instructions from ${file.path} no longer apply.`),
|
||||
...changes.added.map((file) => `New instructions apply from:\n${render([file])}`),
|
||||
...changes.changed.map(({ previous: before, current: after }) => {
|
||||
const patch = createPatch(after.path, before.content, after.content, "", "", { context: 3 })
|
||||
const diff = [
|
||||
`The instructions from ${after.path} changed. Here's the diff:`,
|
||||
"```diff",
|
||||
patch.slice(patch.indexOf("@@")).trimEnd(),
|
||||
"```",
|
||||
].join("\n")
|
||||
const replacement = `The instructions changed:\n${render([after])}`
|
||||
return diff.length < replacement.length ? diff : replacement
|
||||
}),
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
@@ -421,7 +421,11 @@ const layer = Layer.effect(
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
|
||||
yield* bus.publish(
|
||||
Integration.Event.ConnectionUpdated,
|
||||
{ integrationID: attempt.integrationID },
|
||||
{ global: true },
|
||||
)
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}).pipe(Effect.ensuring(close(attempt.scope)))
|
||||
}),
|
||||
@@ -477,7 +481,11 @@ const layer = Layer.effect(
|
||||
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
yield* close(attempt.scope)
|
||||
if (Exit.isFailure(persistence)) return
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
|
||||
yield* bus.publish(
|
||||
Integration.Event.ConnectionUpdated,
|
||||
{ integrationID: attempt.integrationID },
|
||||
{ global: true },
|
||||
)
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}),
|
||||
)
|
||||
@@ -686,6 +694,11 @@ const layer = Layer.effect(
|
||||
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
|
||||
const value = yield* authorize(implementation.refresh(credential.value))
|
||||
yield* credentials.update(credential.id, { value })
|
||||
yield* bus.publish(
|
||||
Integration.Event.ConnectionUpdated,
|
||||
{ integrationID: credential.integrationID },
|
||||
{ global: true },
|
||||
)
|
||||
return value
|
||||
}),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
@@ -711,14 +724,22 @@ const layer = Layer.effect(
|
||||
...(Object.keys(answer).length > 0 ? { configuration: answer } : {}),
|
||||
}),
|
||||
})
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
|
||||
yield* bus.publish(
|
||||
Integration.Event.ConnectionUpdated,
|
||||
{ integrationID: input.integrationID },
|
||||
{ global: true },
|
||||
)
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}),
|
||||
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
|
||||
const credential = yield* credentials.get(credentialID)
|
||||
yield* credentials.update(credentialID, updates)
|
||||
if (credential) {
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
|
||||
yield* bus.publish(
|
||||
Integration.Event.ConnectionUpdated,
|
||||
{ integrationID: credential.integrationID },
|
||||
{ global: true },
|
||||
)
|
||||
}
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}),
|
||||
@@ -726,7 +747,11 @@ const layer = Layer.effect(
|
||||
const credential = yield* credentials.get(credentialID)
|
||||
yield* credentials.remove(credentialID)
|
||||
if (credential) {
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
|
||||
yield* bus.publish(
|
||||
Integration.Event.ConnectionUpdated,
|
||||
{ integrationID: credential.integrationID },
|
||||
{ global: true },
|
||||
)
|
||||
}
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}),
|
||||
|
||||
@@ -111,6 +111,50 @@ describe("InstructionDiscovery", () => {
|
||||
).toBe(false)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
||||
)
|
||||
|
||||
it.effect("renders granular instruction updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
yield* discovery.transform((draft) => {
|
||||
draft.add(file("/global/AGENTS.md", "global"))
|
||||
draft.add(
|
||||
file("/repo/AGENTS.md", ["old", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")),
|
||||
)
|
||||
})
|
||||
const initial = yield* readInitial(yield* discovery.load())
|
||||
|
||||
yield* discovery.transform((draft) => {
|
||||
draft.update("/repo/AGENTS.md", (current) => {
|
||||
current.content = ["new", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")
|
||||
})
|
||||
})
|
||||
const modified = (yield* readUpdate(yield* discovery.load(), initial)).text
|
||||
expect(modified).toContain("The instructions from /repo/AGENTS.md changed. Here's the diff:")
|
||||
expect(modified).toContain("-old\n+new")
|
||||
expect(modified).not.toContain("global")
|
||||
|
||||
const rewritten = state({
|
||||
"core/instructions": [{ path: "/repo/AGENTS.md", content: "old one\nold two\nold three\nold four" }],
|
||||
})
|
||||
yield* discovery.transform((draft) => {
|
||||
draft.remove("/global/AGENTS.md")
|
||||
draft.update("/repo/AGENTS.md", (current) => {
|
||||
current.content = "new"
|
||||
})
|
||||
})
|
||||
expect((yield* readUpdate(yield* discovery.load(), rewritten)).text).toBe(
|
||||
"The instructions changed:\nInstructions from: /repo/AGENTS.md\nnew",
|
||||
)
|
||||
|
||||
yield* discovery.transform((draft) => {
|
||||
draft.add(file("/repo/packages/AGENTS.md", "package"))
|
||||
})
|
||||
const structural = (yield* readUpdate(yield* discovery.load(), initial)).text
|
||||
expect(structural).toContain("The instructions from /global/AGENTS.md no longer apply.")
|
||||
expect(structural).toContain("New instructions apply from:\nInstructions from: /repo/packages/AGENTS.md\npackage")
|
||||
expect(structural).not.toContain("Instructions from: /global/AGENTS.md\nglobal")
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
@@ -168,20 +212,15 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||
yield* emitAndWait({ type: "update", path: packageFile })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
|
||||
`Instructions from: ${packageFile}\nchanged`,
|
||||
)
|
||||
const changed = (yield* readUpdate(yield* discovery.load(), initialized)).text
|
||||
expect(changed).toContain(`The instructions changed:\nInstructions from: ${packageFile}\nchanged`)
|
||||
expect(changed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
|
||||
|
||||
yield* Effect.promise(() => fs.rm(packageFile))
|
||||
yield* emitAndWait({ type: "delete", path: packageFile })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
||||
[
|
||||
"These instructions replace all previously loaded ambient instructions.",
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
`Instructions from: ${projectFile}\nproject`,
|
||||
`Instructions from: ${sharedFile}\nshared`,
|
||||
].join("\n\n"),
|
||||
)
|
||||
const removed = (yield* readUpdate(yield* discovery.load(), initialized)).text
|
||||
expect(removed).toContain(`The instructions from ${packageFile} no longer apply.`)
|
||||
expect(removed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
|
||||
|
||||
yield* Effect.promise(() => fs.rm(globalFile))
|
||||
yield* emitAndWait({ type: "delete", path: globalFile })
|
||||
|
||||
@@ -486,6 +486,45 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("routes global events to every location", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
|
||||
).pipe(
|
||||
Effect.flatMap(([first, second]) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const firstContext = yield* locations.contextEffect(
|
||||
Location.Ref.make({ directory: AbsolutePath.make(first.path) }),
|
||||
)
|
||||
const secondContext = yield* locations.contextEffect(
|
||||
Location.Ref.make({ directory: AbsolutePath.make(second.path) }),
|
||||
)
|
||||
const received = { first: 0, second: 0 }
|
||||
yield* bus.subscribe(Config.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Effect.sync(() => received.first++)),
|
||||
Effect.provideContext(firstContext),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* bus.subscribe(Config.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Effect.sync(() => received.second++)),
|
||||
Effect.provideContext(secondContext),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* bus.publish(Config.Event.Updated, {}, { global: true })
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
expect(received).toEqual({ first: 1, second: 1 })
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reuses cached services for constructed and decoded location refs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -94,6 +94,7 @@ export function Composer(props: ComposerProps) {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "composer",
|
||||
enabled: () => props.open,
|
||||
priority: 1,
|
||||
commands: [
|
||||
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
|
||||
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
|
||||
|
||||
@@ -23,19 +23,6 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
|
||||
const selectedEntry = createMemo(() => entries()[store.selected])
|
||||
|
||||
const keymap = Keymap.use()
|
||||
createEffect(() => {
|
||||
if (!composer.active("shell")) return
|
||||
const cleanup = keymap.intercept("key", ({ event, consume }) => {
|
||||
if (event.name !== "d" || !event.ctrl) return
|
||||
if (!shortcuts.list("composer.shell.kill").includes("ctrl+d")) return
|
||||
if (!selectedEntry()) return
|
||||
consume()
|
||||
keymap.dispatch("composer.shell.kill")
|
||||
})
|
||||
onCleanup(cleanup)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (store.selected >= entries().length) setStore("selected", Math.max(0, entries().length - 1))
|
||||
})
|
||||
@@ -63,6 +50,7 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "composer",
|
||||
enabled: () => composer.active("shell"),
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "composer.shell.up",
|
||||
|
||||
@@ -164,6 +164,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "composer",
|
||||
enabled: () => composer.active("subagents"),
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "composer.subagent.up",
|
||||
|
||||
@@ -2872,16 +2872,8 @@ function Shell(props: ToolProps) {
|
||||
})
|
||||
const maxLines = 10
|
||||
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
|
||||
const prompt = createMemo(() => (workdir() && workdir() !== "." ? `${workdir()}$` : "$"))
|
||||
const input = createMemo(() => {
|
||||
const cmd = command()
|
||||
if (!cmd) return ""
|
||||
// While running, the workdir prompt shares the spinner's text column; when
|
||||
// settled, the prompt renders as its own column so wrapped command lines
|
||||
// keep a stable hanging indent instead of jumping to the card inset.
|
||||
if (isRunning() && prompt() !== "$") return `${prompt()} ${cmd}`
|
||||
return cmd
|
||||
})
|
||||
const prefix = createMemo(() => (workdir() && workdir() !== "." ? `cd ${workdir()} && ` : ""))
|
||||
const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${prefix()}${command()}` : ""))
|
||||
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
|
||||
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
|
||||
const limited = createMemo(() => {
|
||||
@@ -2910,15 +2902,7 @@ function Shell(props: ToolProps) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={isRunning()}
|
||||
fallback={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text.default}>{prompt()}</text>
|
||||
<text fg={theme.text.default}>{limitedInput()}</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Show when={isRunning()} fallback={<text fg={theme.text.default}>{limitedInput()}</text>}>
|
||||
<Spinner color={color()}>{limitedInput()}</Spinner>
|
||||
</Show>
|
||||
<Show when={limitedOutput()}>
|
||||
|
||||
@@ -95,7 +95,6 @@ async function renderComposer(
|
||||
<TestTuiContexts directory={directory}>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ keybinds })}>
|
||||
<Keymap.Provider>
|
||||
<AppExit />
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
@@ -107,6 +106,7 @@ async function renderComposer(
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
<AppExit />
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
@@ -173,11 +173,13 @@ test("disabled shell bindings have no component fallbacks", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("shell kill binding overrides app exit", async () => {
|
||||
const composer = await renderComposer("shell", {}, true)
|
||||
test("configured composer bindings work with a focused textarea", async () => {
|
||||
const composer = await renderComposer("subagents", { "composer.shell.kill": "ctrl+u" }, true)
|
||||
try {
|
||||
composer.app.mockInput.pressArrow("right")
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.app.captureCharFrame()).toContain("bun test")
|
||||
composer.app.mockInput.pressKey("d", { ctrl: true })
|
||||
composer.app.mockInput.pressKey("u", { ctrl: true })
|
||||
await wait(() => composer.removed.length === 1)
|
||||
expect(composer.removed).toEqual(["sh-a"])
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user