Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 4af6f98d82 refactor(core): simplify automatic web search 2026-08-13 12:23:16 -04:00
Kit Langton 87e8690090 feat(core): add automatic web search routing 2026-08-13 12:18:20 -04:00
32 changed files with 373 additions and 520 deletions
+10 -14
View File
@@ -89,8 +89,6 @@ 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>
}
@@ -452,12 +450,11 @@ 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?.global
? undefined
: (options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return yield* publishEvent(
definition,
{
@@ -487,12 +484,11 @@ export function configured(options?: Options) {
}),
)
}
const location = options?.global
? undefined
: (options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return {
definition,
aggregateID,
+11 -37
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 { Clock, Context, Duration, Effect, Layer, Schema, Scope } from "effect"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem.js"
@@ -22,64 +22,38 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
export const ripgrepLayer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const scope = yield* Scope.Scope
const clock = yield* Clock.Clock
const files: string[] = []
const directories = new Set<string>()
const home = Protected.isHome(location.directory)
let index = { files: [] as string[], directories: new Set<string>() }
let initialized = false
let settledAt = Number.NEGATIVE_INFINITY
let refreshing = false
const scan = Effect.gen(function* () {
const next = { files: [] as string[], directories: new Set<string>() }
if (!initialized) index = next
yield* ripgrep.find({
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(() => {
next.files.push(entry.path)
files.push(entry.path)
const parts = entry.path.split("/")
parts
.slice(0, -1)
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
}),
})
index = next
initialized = true
}).pipe(
Effect.orDie,
Effect.ensuring(
Effect.sync(() => {
settledAt = clock.currentTimeMillisUnsafe()
refreshing = false
}),
),
)
const refresh = Effect.sync(() => {
if (refreshing || clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return
refreshing = true
return scan
}).pipe(Effect.flatMap((effect) => (effect ? effect.pipe(Effect.forkIn(scope)) : Effect.void)))
yield* refresh
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
return Service.of({
find: (input) =>
Effect.gen(function* () {
yield* refresh
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)
+2 -26
View File
@@ -2,7 +2,6 @@ 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"
@@ -82,7 +81,8 @@ export const layer = (options?: Options) =>
read: Effect.succeed(value),
render: {
initial: render,
changed: renderUpdate,
changed: (_previous, current) =>
`These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
removed: () => "Previously loaded instructions no longer apply.",
},
})
@@ -120,27 +120,3 @@ 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")
}
+5 -30
View File
@@ -421,11 +421,7 @@ 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 },
{ global: true },
)
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}).pipe(Effect.ensuring(close(attempt.scope)))
}),
@@ -481,11 +477,7 @@ 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 },
{ global: true },
)
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}),
)
@@ -694,11 +686,6 @@ 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) {
@@ -724,22 +711,14 @@ const layer = Layer.effect(
...(Object.keys(answer).length > 0 ? { configuration: answer } : {}),
}),
})
yield* bus.publish(
Integration.Event.ConnectionUpdated,
{ integrationID: input.integrationID },
{ global: true },
)
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
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 },
{ global: true },
)
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* bus.publish(Integration.Event.Updated, {})
}),
@@ -747,11 +726,7 @@ 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 },
{ global: true },
)
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* bus.publish(Integration.Event.Updated, {})
}),
+6 -7
View File
@@ -55,7 +55,7 @@ export const Plugin = {
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
websearch.default().pipe(
Effect.flatMap((provider) => {
if (!provider) return ctx.websearch.query(input)
if (!provider || provider === WebSearch.AUTO) return ctx.websearch.query(input)
return context
.progress({ provider: provider.id })
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID: provider.id })))
@@ -67,8 +67,7 @@ export const Plugin = {
Effect.gen(function* () {
if (yield* websearch.default()) return yield* Effect.void
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
if (!providers.length) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
@@ -83,11 +82,11 @@ export const Plugin = {
options: [
{
value: "allow",
label: `Allow web search via ${defaultProvider.name}`,
label: "Allow web search",
},
{
value: "choose",
label: "Choose another provider",
label: "Choose a specific provider",
},
{ value: "disable", label: "Disable web search" },
],
@@ -123,10 +122,10 @@ export const Plugin = {
: undefined
if (selection?.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? defaultProvider.id
const providerID = selection?.answer.provider ?? WebSearch.AUTO
if (
typeof providerID !== "string" ||
!providers.some((provider) => provider.id === providerID)
(providerID !== WebSearch.AUTO && !providers.some((provider) => provider.id === providerID))
)
return yield* new WebSearch.ProviderRequiredError()
return yield* kv.set("websearch:provider", providerID)
+25 -11
View File
@@ -1,7 +1,7 @@
export * as WebSearch from "./websearch.js"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Random, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
@@ -10,6 +10,8 @@ import { State } from "./state.js"
export const ID = WebSearch.ID
export type ID = WebSearch.ID
export const AUTO = WebSearch.AUTO
export const Provider = WebSearch.Provider
export type Provider = WebSearch.Provider
@@ -52,7 +54,7 @@ export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledErro
export interface Interface extends State.Transformable<Draft> {
readonly providers: () => Effect.Effect<readonly Provider[]>
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
readonly default: () => Effect.Effect<Provider | typeof AUTO | undefined, DisabledError>
readonly query: (input: Input) => Effect.Effect<Response, Error>
}
@@ -60,14 +62,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/We
type Data = {
readonly providers: Map<ID, ProviderImplementation>
defaultProviderID?: ID
defaultProviderID?: WebSearch.Selection
}
export type Draft = {
add: (provider: ProviderImplementation) => void
default: {
get: () => ID | undefined
set: (providerID: ID) => void
get: () => WebSearch.Selection | undefined
set: (providerID: WebSearch.Selection) => void
}
}
@@ -96,11 +98,13 @@ const layer = Layer.effect(
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
const data = state.get()
if (data.defaultProviderID === AUTO) return AUTO
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
if (configured) return configured
const stored = yield* kv.get("websearch:provider")
if (stored === false) return yield* new DisabledError()
if (typeof stored !== "string") return
if (stored === AUTO) return AUTO
return data.providers.get(ID.make(stored))
})
@@ -109,9 +113,21 @@ const layer = Layer.effect(
if (input.providerID) return yield* requireProvider(providers, input.providerID)
const provider = yield* defaultProvider()
if (!provider) return yield* new ProviderRequiredError()
if (provider === AUTO) {
if (!providers.size) return yield* new ProviderRequiredError()
return yield* Random.shuffle(providers.values())
}
return provider
})
const execute = Effect.fn("WebSearch.execute")(function* (provider: ProviderImplementation, input: Input) {
const results = yield* provider.execute({ query: input.query }).pipe(
Effect.flatMap(decodeResults),
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
)
return new Response({ providerID: provider.id, results })
})
return Service.of({
transform: state.transform,
reload: state.reload,
@@ -123,15 +139,13 @@ const layer = Layer.effect(
}),
default: Effect.fn("WebSearch.defaultInfo")(function* () {
const provider = yield* defaultProvider()
if (provider === AUTO) return AUTO
return provider && { id: provider.id, name: provider.name }
}),
query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(input)
const results = yield* provider.execute({ query: input.query }).pipe(
Effect.flatMap(decodeResults),
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
)
return new Response({ providerID: provider.id, results })
const route = yield* resolve(input)
if (Array.isArray(route)) return yield* Effect.firstSuccessOf(route.map((provider) => execute(provider, input)))
return yield* execute(route, input)
}),
})
}),
+1 -68
View File
@@ -1,8 +1,7 @@
import { describe, expect, test } from "bun:test"
import os from "os"
import path from "path"
import { Deferred, Effect, Layer } from "effect"
import { TestClock } from "effect/testing"
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"
@@ -57,70 +56,4 @@ describe("FileSystemSearch", () => {
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
let scans = 0
const initial = Effect.runSync(Deferred.make<void>())
const started = Effect.runSync(Deferred.make<void>())
const release = Effect.runSync(Deferred.make<void>())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
if (scans > 1) {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
const entry = FileSystem.Entry.make({
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
if (scans === 1) yield* Deferred.succeed(initial, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Deferred.await(initial)
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
expect(scans).toBe(1)
yield* TestClock.adjust("10 seconds")
yield* search.find({ query: "old", type: "file" })
yield* Deferred.await(started)
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
expect(scans).toBe(2)
yield* Deferred.succeed(release, undefined)
const refreshed = yield* Effect.gen(function* () {
yield* Effect.yieldNow
return yield* search.find({ query: "new", type: "file" })
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
expect(scans).toBe(2)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
})
})
@@ -111,50 +111,6 @@ 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", () => {
@@ -212,15 +168,20 @@ describe("ConfigInstructionPlugin.Plugin", () => {
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
yield* emitAndWait({ type: "update", path: packageFile })
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`)
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
`Instructions from: ${packageFile}\nchanged`,
)
yield* Effect.promise(() => fs.rm(packageFile))
yield* emitAndWait({ type: "delete", path: packageFile })
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`)
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"),
)
yield* Effect.promise(() => fs.rm(globalFile))
yield* emitAndWait({ type: "delete", path: globalFile })
-39
View File
@@ -486,45 +486,6 @@ 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()),
+7 -5
View File
@@ -80,6 +80,7 @@ const websearch = Layer.succeed(
Effect.gen(function* () {
const stored = values.get("websearch:provider")
if (stored === false) return yield* new WebSearch.DisabledError()
if (stored === WebSearch.AUTO) return WebSearch.AUTO
return typeof stored === "string" ? providers.find((provider) => provider.id === stored) : undefined
}),
query: (input) =>
@@ -93,6 +94,7 @@ const websearch = Layer.succeed(
}
if (queryError) return yield* queryError
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
if (stored === WebSearch.AUTO) return result
if (typeof stored === "string")
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
return result
@@ -234,7 +236,7 @@ describe("WebSearchTool registration", () => {
}),
)
it.effect("asks once and uses the default provider when web search is first enabled", () =>
it.effect("asks once and enables automatic provider selection", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "allow" } }
@@ -247,7 +249,7 @@ describe("WebSearchTool registration", () => {
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
}),
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
expect(values.get("websearch:provider")).toBe("exa")
expect(values.get("websearch:provider")).toBe(WebSearch.AUTO)
expect(queries).toHaveLength(2)
expect(formRequests).toEqual([
{
@@ -264,11 +266,11 @@ describe("WebSearchTool registration", () => {
options: [
{
value: "allow",
label: "Allow web search via Exa",
label: "Allow web search",
},
{
value: "choose",
label: "Choose another provider",
label: "Choose a specific provider",
},
{ value: "disable", label: "Disable web search" },
],
@@ -353,7 +355,7 @@ describe("WebSearchTool registration", () => {
expect(results.every((item) => item.status === "completed")).toBe(true)
expect(formRequests).toHaveLength(1)
expect(values.get("websearch:provider")).toBe("exa")
expect(values.get("websearch:provider")).toBe(WebSearch.AUTO)
}),
)
+161 -13
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect"
import { Effect, Exit, Random, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
@@ -9,7 +9,7 @@ import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
const register = (id: string) =>
const register = (id: string, behavior: "results" | "empty" | "fail" = "results") =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const providerID = WebSearch.ID.make(id)
@@ -19,17 +19,24 @@ const register = (id: string) =>
id: providerID,
name: id.toUpperCase(),
execute: (input) =>
Effect.sync(() => {
calls.push(input)
return [
{
url: `https://${id}.example.com`,
title: input.query,
content: `${id}: ${input.query}`,
time: {},
},
]
}),
Effect.sync(() => calls.push(input)).pipe(
Effect.andThen(
behavior === "fail"
? Effect.fail(new Error(`${id} failed`))
: Effect.succeed(
behavior === "empty"
? []
: [
{
url: `https://${id}.example.com`,
title: input.query,
content: `${id}: ${input.query}`,
time: {},
},
],
),
),
),
})
})
return { providerID, calls }
@@ -60,6 +67,21 @@ describe("WebSearch", () => {
}),
)
it.effect("keeps explicit providers strict when automatic selection is enabled", () =>
Effect.gen(function* () {
const exa = yield* register("exa", "fail")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.transform((draft) => draft.default.set(WebSearch.AUTO))
const error = yield* websearch.query({ query: "strict", providerID: exa.providerID }).pipe(Effect.flip)
expect(error).toMatchObject({ _tag: "WebSearch.Request", providerID: exa.providerID })
expect(exa.calls).toEqual([{ query: "strict" }])
expect(parallel.calls).toEqual([])
}),
)
it.effect("requires a provider when no default is set", () =>
Effect.gen(function* () {
yield* register("exa")
@@ -81,6 +103,20 @@ describe("WebSearch", () => {
}),
)
it.effect("keeps fixed configured providers strict", () =>
Effect.gen(function* () {
const exa = yield* register("exa", "fail")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.transform((draft) => draft.default.set(exa.providerID))
const error = yield* websearch.query({ query: "configured" }).pipe(Effect.flip)
expect(error).toMatchObject({ _tag: "WebSearch.Request", providerID: exa.providerID })
expect(parallel.calls).toEqual([])
}),
)
it.effect("uses the provider stored in KV", () =>
Effect.gen(function* () {
yield* register("exa")
@@ -94,6 +130,118 @@ describe("WebSearch", () => {
}),
)
it.effect("keeps fixed KV providers strict", () =>
Effect.gen(function* () {
const exa = yield* register("exa", "fail")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
const kv = yield* KV.Service
yield* kv.set("websearch:provider", exa.providerID)
const error = yield* websearch.query({ query: "fixed" }).pipe(Effect.flip)
expect(error).toMatchObject({ _tag: "WebSearch.Request", providerID: exa.providerID })
expect(parallel.calls).toEqual([])
yield* kv.remove("websearch:provider")
}),
)
it.effect("automatically tries each provider at most once until one succeeds", () =>
Effect.gen(function* () {
const order = yield* Random.shuffle(["exa", "parallel", "firecrawl"]).pipe(Random.withSeed("fallback"))
const registered = yield* Effect.forEach(["exa", "parallel", "firecrawl"], (id) =>
register(id, id === order.at(-1) ? "results" : "fail"),
)
const websearch = yield* WebSearch.Service
const kv = yield* KV.Service
yield* kv.set("websearch:provider", WebSearch.AUTO)
const response = yield* websearch.query({ query: "automatic" }).pipe(Random.withSeed("fallback"))
expect(response.providerID).toBe(WebSearch.ID.make(order.at(-1)!))
expect(registered.flatMap((provider) => provider.calls)).toHaveLength(3)
expect(registered.every((provider) => provider.calls.length === 1)).toBe(true)
yield* kv.remove("websearch:provider")
}),
)
it.effect("stops automatic fallback on empty results", () =>
Effect.gen(function* () {
const empty = yield* register("empty", "empty")
const fallback = yield* register("fallback")
const websearch = yield* WebSearch.Service
yield* websearch.transform((draft) => draft.default.set(WebSearch.AUTO))
const response = yield* websearch.query({ query: "empty" }).pipe(Random.withSeed("empty-first"))
expect(response.results).toEqual([])
expect(empty.calls).toHaveLength(1)
expect(fallback.calls).toHaveLength(0)
}),
)
it.effect("returns the final request error when every automatic provider fails", () =>
Effect.gen(function* () {
const exa = yield* register("exa", "fail")
const parallel = yield* register("parallel", "fail")
const websearch = yield* WebSearch.Service
yield* websearch.transform((draft) => draft.default.set(WebSearch.AUTO))
const expected = yield* Random.shuffle([exa.providerID, parallel.providerID]).pipe(Random.withSeed("all-fail"))
const error = yield* websearch.query({ query: "failure" }).pipe(Random.withSeed("all-fail"), Effect.flip)
expect(error).toMatchObject({ _tag: "WebSearch.Request", providerID: expected.at(-1) })
expect(exa.calls).toHaveLength(1)
expect(parallel.calls).toHaveLength(1)
}),
)
it.effect("supports zero and one registered provider in automatic mode", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
yield* websearch.transform((draft) => draft.default.set(WebSearch.AUTO))
expect((yield* websearch.query({ query: "zero" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
const only = yield* register("only")
expect((yield* websearch.query({ query: "one" })).providerID).toBe(only.providerID)
expect(only.calls).toHaveLength(1)
}),
)
it.effect("lets an automatic configured default override a fixed KV provider", () =>
Effect.gen(function* () {
const fixed = yield* register("fixed", "fail")
const fallback = yield* register("fallback")
const websearch = yield* WebSearch.Service
const kv = yield* KV.Service
yield* kv.set("websearch:provider", fixed.providerID)
yield* websearch.transform((draft) => draft.default.set(WebSearch.AUTO))
expect((yield* websearch.query({ query: "config" })).providerID).toBe(fallback.providerID)
expect(fixed.calls.length).toBeLessThanOrEqual(1)
expect(fallback.calls).toHaveLength(1)
yield* kv.remove("websearch:provider")
}),
)
it.effect("can start automatic selection with any registered provider", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.transform((draft) => draft.default.set(WebSearch.AUTO))
const selected = yield* Effect.forEach(["a", "b", "c", "d", "e", "f"], (seed) =>
websearch.query({ query: seed }).pipe(
Random.withSeed(seed),
Effect.map((response) => response.providerID),
),
)
expect(new Set(selected)).toEqual(new Set([exa.providerID, parallel.providerID]))
}),
)
it.effect("fails when web search is explicitly disabled", () =>
Effect.gen(function* () {
yield* register("exa")
+1 -1
View File
@@ -4,5 +4,5 @@ import { Schema } from "effect"
import { WebSearch } from "../websearch.js"
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
provider: WebSearch.ID,
provider: WebSearch.Selection,
}) {}
+5
View File
@@ -7,6 +7,11 @@ import { optional } from "./schema.js"
export const ID = Schema.String.pipe(Schema.brand("WebSearch.ID"))
export type ID = typeof ID.Type
export const AUTO = "auto" as const
export const Selection = Schema.Union([Schema.Literal(AUTO), ID])
export type Selection = typeof Selection.Type
export interface Provider extends Schema.Schema.Type<typeof Provider> {}
export const Provider = Schema.Struct({
id: ID,
+6
View File
@@ -6,8 +6,14 @@ import { ConfigMCP } from "../src/config/mcp.js"
import { ConfigProvider } from "../src/config/provider.js"
import { Mcp } from "../src/mcp.js"
import { AbsolutePath } from "../src/schema.js"
import { WebSearch } from "../src/websearch.js"
describe("Config.Entry", () => {
test("accepts automatic web search provider selection", () => {
const config = new Config.Info({ websearch: { provider: WebSearch.AUTO } })
expect(config.websearch?.provider).toBe(WebSearch.AUTO)
})
test("round-trips every configuration entry type", () => {
const entries = [
new Config.Document({
+1 -2
View File
@@ -115,10 +115,9 @@ export function elements(renderer: CliRenderer): Element[] {
}
export function state(harness: Harness) {
const renderable = harness.renderer.currentFocusedRenderable?.num
return {
focused: {
...(renderable === undefined ? {} : { renderable }),
renderable: harness.renderer.currentFocusedRenderable?.num,
editor: Boolean(harness.renderer.currentFocusedEditor),
},
elements: elements(harness.renderer),
-12
View File
@@ -14,18 +14,6 @@ test("matches literal screen text", () => {
expect(matches(harness, "opencode")).toBe(false)
})
test("omits an absent focused renderable from state", () => {
const harness = {
renderer: {
root: { getChildren: () => [] },
currentFocusedRenderable: undefined,
currentFocusedEditor: undefined,
},
} as unknown as Harness
expect(state(harness)).toEqual({ focused: { editor: false }, elements: [] })
})
test("normalizes named keys for OpenTUI", async () => {
const pressed: Array<readonly [string, object | undefined]> = []
const harness = {
@@ -1,11 +1,10 @@
import { createMemo, createSignal } from "solid-js"
import { useConfig } from "../config"
import { DialogSelect } from "../ui/dialog-select"
import { useTheme } from "../context/theme"
import { useToast } from "../ui/toast"
type Experiment = {
id: string
id: "tab_drafts"
title: string
description: string
}
@@ -13,30 +12,36 @@ type Experiment = {
// In-flight features anyone can opt into. Each entry is temporary: an
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = []
export const experiments: Experiment[] = [
{
id: "tab_drafts",
title: "Per-tab prompt drafts",
description: "Keep unsent prompt drafts on the tab where they were written. New sessions start blank.",
},
]
export function DialogExperiments() {
const config = useConfig()
const theme = useTheme()
const toast = useToast()
const [selected, setSelected] = createSignal<Experiment>()
const [selected, setSelected] = createSignal(0)
const [saving, setSaving] = createSignal(false)
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
const options = createMemo(() =>
experiments.map((experiment) => ({
experiments.map((experiment, index) => ({
title: experiment.title,
category: "Experiments",
searchText: experiment.description,
footer: enabled(experiment) ? "on" : "off",
value: experiment,
value: index,
})),
)
// All experiments are booleans, so either direction toggles.
async function change(experiment = selected()) {
async function change(index = selected()) {
if (saving()) return
const experiment = experiments[index]
if (!experiment) return
const next = !enabled(experiment)
setSaving(true)
@@ -53,33 +58,23 @@ export function DialogExperiments() {
<DialogSelect
title="Experiments"
options={options()}
renderFilter={experiments.length > 0}
onMove={(option) => setSelected(option.value)}
onSelect={(option) => void change(option.value)}
emptyView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No experiments available</text>
</box>
}
footerHints={experiments.length > 0 ? [{ title: "←/→", label: "change" }] : []}
bindings={
experiments.length > 0
? [
{
bind: "left",
title: "Previous value",
group: "Experiments",
run: () => void change(),
},
{
bind: "right",
title: "Next value",
group: "Experiments",
run: () => void change(),
},
]
: []
}
footerHints={[{ title: "←/→", label: "change" }]}
bindings={[
{
bind: "left",
title: "Previous value",
group: "Experiments",
run: () => void change(),
},
{
bind: "right",
title: "Next value",
group: "Experiments",
run: () => void change(),
},
]}
/>
)
}
@@ -1,18 +1,30 @@
import type { PromptInfo } from "../../prompt/history"
// Holds one in-progress draft per tab across Prompt remounts. A draft is
// consumed on take: restoring it moves it out of the stash, so a stale copy
// never shadows newer input.
// Holds one in-progress draft per slot across Prompt remounts. The undefined
// key is the default single global slot that follows focus across tabs; the
// tab_drafts experiment keys drafts by the tab (sessionID or "home") they
// were written in. A draft is consumed on take: restoring it moves it out of
// the stash, so a stale copy never shadows newer input.
export type DraftEntry = { prompt: PromptInfo; cursor: number }
const byTab = new Map<string | undefined, DraftEntry>()
let global: DraftEntry | undefined
const byTab = new Map<string, DraftEntry>()
export function takeDraft(sessionID: string | undefined) {
const entry = byTab.get(sessionID)
byTab.delete(sessionID)
export function takeDraft(key: string | undefined) {
if (key === undefined) {
const entry = global
global = undefined
return entry
}
const entry = byTab.get(key)
byTab.delete(key)
return entry
}
export function saveDraft(sessionID: string | undefined, entry: DraftEntry) {
byTab.set(sessionID, entry)
export function saveDraft(key: string | undefined, entry: DraftEntry) {
if (key === undefined) {
global = entry
return
}
byTab.set(key, entry)
}
+3 -2
View File
@@ -678,9 +678,10 @@ export function Prompt(props: PromptProps) {
// instance belongs to exactly one tab. Reading props.sessionID lazily would
// observe the *next* route during onCleanup and stash under the wrong tab.
const stashSessionID = props.sessionID
const stashKey = () => (config.experimental?.tab_drafts === true ? (stashSessionID ?? "home") : undefined)
onMount(() => {
const saved = takeDraft(stashSessionID)
const saved = takeDraft(stashKey())
if (store.prompt.text) return
if (saved && saved.prompt.text) {
input.setText(saved.prompt.text)
@@ -693,7 +694,7 @@ export function Prompt(props: PromptProps) {
onCleanup(() => {
disposed = true
if (store.prompt.text) {
saveDraft(stashSessionID, { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
saveDraft(stashKey(), { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
}
setInputTarget(undefined)
props.ref?.(undefined)
+3 -22
View File
@@ -21,7 +21,6 @@ import {
moveSessionTab,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
sessionTabDetail,
sessionTabShortcutLabel,
seedSessionTabMotion,
sessionTabOverflowWidth,
@@ -409,20 +408,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
const fixture = tabs.detail?.(tab.sessionID)
if (fixture !== undefined) return fixture
if (fixture !== undefined) return Locale.takeWidth(fixture, titleWidth())
const value = session()
const currentProject = project()
const projectLabel = projectName(currentProject, value?.location.directory) ?? ""
const vcs = value ? data.location.vcs.info(value.location) : undefined
const location = value ? data.location.info(value.location) : undefined
const worktree = !!location && location.project.directory !== location.project.canonical
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default, worktree)
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
})
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
const detailFades = createMemo(
() => marqueeOverflows(detail(), titleWidth()) && titleWidth() > FADE_WIDTH,
)
const background = createMemo(() => {
if (selected()) return theme.background.action.primary.selected
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
@@ -464,10 +453,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.42))
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const detailTextColor = (index: number) =>
detailFades()
? fadeTitleColor(detailColor(), pulseBackground(), index, visibleDetailParts().length, 0)
: detailColor()
const glows = () => status().glows
const previous = createMemo(() => items()[index() - 1])
const previousStatus = createMemo(() => {
@@ -685,11 +670,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
<Show when={detailFades()} fallback={visibleDetail()}>
<For each={visibleDetailParts()}>
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
</For>
</Show>
{detail()}
</text>
</box>
</box>
+7 -3
View File
@@ -192,9 +192,13 @@ export const Info = Schema.Struct({
}),
}),
).annotate({ description: "Debugging settings" }),
experimental: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "Experimental features that may change or be removed at any time",
}),
experimental: Schema.optional(
Schema.Struct({
tab_drafts: Schema.optional(Schema.Boolean).annotate({
description: "Keep unsent prompt drafts on the tab where they were written",
}),
}),
).annotate({ description: "Experimental features that may change or be removed at any time" }),
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
cursor: Schema.optional(Cursor),
+3 -6
View File
@@ -94,7 +94,7 @@ type Store = {
location: Record<string, LocationData>
}
export function locationKey(location: LocationRef) {
function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
@@ -1214,9 +1214,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
default() {
return defaultLocation()
},
syncInfo(ref?: LocationRef) {
async sync(ref?: LocationRef) {
const current = ref ?? defaultLocation()
return sync.run(`location:${locationKey(current)}`, async () => {
await sync.run(`location:${locationKey(current)}`, async () => {
const location = await client.api.location.get({ location: locationQuery(current) })
const key = locationKey(location)
if (!store.location[key]) setStore("location", key, {})
@@ -1225,9 +1225,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
}
})
},
async sync(ref?: LocationRef) {
await result.location.syncInfo(ref)
const location = ref ?? defaultLocation()
await Promise.all([
result.location.vcs.sync(location),
@@ -13,16 +13,6 @@ export function sessionTabShortcutLabel(index: number) {
return "·"
}
export function sessionTabDetail(
project: string,
current: string | undefined,
defaultBranch: string | undefined,
worktree: boolean,
) {
const branch = worktree && current !== defaultBranch ? current : undefined
return branch && project ? `${project}${branch}` : (branch ?? project)
}
export type SessionTabHistory = {
entries: readonly string[]
index: number
+7 -22
View File
@@ -2,7 +2,7 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { locationKey, useData } from "./data"
import { useData } from "./data"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { useEvent } from "./event"
import { useRoute } from "./route"
@@ -159,9 +159,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
// Load lightweight session and location metadata concurrently so persisted tabs can resolve
// their project and branch labels. Delay the heavier per-tab data so the visible session keeps
// the first connection slots and switches still render from a warm cache.
// Load lightweight session metadata concurrently so persisted tabs can resolve their project
// labels immediately. Delay the heavier per-tab data so the visible session keeps the first
// connection slots and switches still render from a warm cache.
const openTabSessions = createMemo(() =>
state()
.tabs.map((tab) => tab.sessionID)
@@ -171,25 +171,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
createEffect(() => {
if (!enabled()) return
if (client.connection.status() !== "connected") return
const signature = openTabSessions()
if (signature === "") return
const sessionIDs = signature.split("\n")
const sessionIDs = openTabSessions()
if (sessionIDs === "") return
void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
let stale = false
void (async () => {
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID)))
if (stale) return
const locations = new Map(
sessionIDs
.map((sessionID) => data.session.get(sessionID)?.location)
.filter((location) => location !== undefined)
.map((location) => [locationKey(location), location]),
)
await Promise.allSettled(
Array.from(locations.values(), (location) =>
Promise.all([data.location.syncInfo(location), data.location.vcs.sync(location)]),
),
)
})()
const timer = setTimeout(async () => {
const sessions = state()
.tabs.map((tab) => tab.sessionID)
@@ -94,7 +94,6 @@ 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) },
@@ -50,7 +50,6 @@ export function ShellTab(props: { sessionID: string }) {
Keymap.createLayer(() => ({
mode: "composer",
enabled: () => composer.active("shell"),
priority: 1,
commands: [
{
id: "composer.shell.up",
@@ -164,7 +164,6 @@ export function SubagentsTab(props: { sessionID: string }) {
Keymap.createLayer(() => ({
mode: "composer",
enabled: () => composer.active("subagents"),
priority: 1,
commands: [
{
id: "composer.subagent.up",
+19 -3
View File
@@ -2872,8 +2872,16 @@ function Shell(props: ToolProps) {
})
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const prefix = createMemo(() => (workdir() && workdir() !== "." ? `cd ${workdir()} && ` : ""))
const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${prefix()}${command()}` : ""))
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 content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
const limited = createMemo(() => {
@@ -2902,7 +2910,15 @@ function Shell(props: ToolProps) {
)
}
>
<Show when={isRunning()} fallback={<text fg={theme.text.default}>{limitedInput()}</text>}>
<Show
when={isRunning()}
fallback={
<box flexDirection="row" gap={1}>
<text fg={theme.text.default}>{prompt()}</text>
<text fg={theme.text.default}>{limitedInput()}</text>
</box>
}
>
<Spinner color={color()}>{limitedInput()}</Spinner>
</Show>
<Show when={limitedOutput()}>
@@ -23,11 +23,7 @@ const sessions = {
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
async function renderComposer(
defaultTab: "subagents" | "shell",
keybinds: Partial<TuiKeybind.Keybinds>,
focusedTextarea = false,
) {
async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Partial<TuiKeybind.Keybinds>) {
const events = createEventStream()
const interrupted: string[] = []
const removed: string[] = []
@@ -73,21 +69,7 @@ async function renderComposer(
.then(() => wait(() => data.session.status("child-a") === "running"))
.then(() => ready.resolve(), ready.reject)
})
return (
<>
{focusedTextarea && <textarea focused={true} initialValue="draft" />}
<Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
</>
)
}
function AppExit() {
Keymap.createLayer(() => ({
mode: "global",
commands: [{ id: "app.exit", title: "Exit", group: "System", run: () => {} }],
}))
Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
return null
return <Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
}
const app = await testRender(
@@ -106,7 +88,6 @@ async function renderComposer(
</LocationProvider>
</DataProvider>
</ClientProvider>
<AppExit />
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
@@ -173,20 +154,6 @@ test("disabled shell bindings have no component fallbacks", async () => {
}
})
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("u", { ctrl: true })
await wait(() => composer.removed.length === 1)
expect(composer.removed).toEqual(["sh-a"])
} finally {
composer.app.renderer.destroy()
}
})
function session(id: string, title: string, parentID?: string) {
return {
id,
@@ -11,20 +11,11 @@ import {
reopenSessionTab,
seedSessionTabMotion,
sessionTabComplete,
sessionTabDetail,
sessionTabOverflowWidth,
sessionTabShortcutLabel,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("appends the branch to the project detail", () => {
expect(sessionTabDetail("opencode", "feature/sidebar", "main", true)).toBe("opencode ⎇ feature/sidebar")
expect(sessionTabDetail("opencode", "feature/sidebar", undefined, true)).toBe("opencode ⎇ feature/sidebar")
expect(sessionTabDetail("opencode", "feature/sidebar", "main", false)).toBe("opencode")
expect(sessionTabDetail("opencode", "main", "main", true)).toBe("opencode")
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
})
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
"1",
@@ -28,14 +28,7 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
async function renderSessionTabs(
initialSessionID: string,
options?: {
state?: string
title?: string
home?: boolean
persisted?: string[]
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
},
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
) {
const temporary = options?.state ? undefined : await tmpdir()
const state = options?.state ?? temporary!.path
@@ -52,25 +45,7 @@ async function renderSessionTabs(
}
const events = createEventStream()
const sessions: string[] = []
const locations: string[] = []
const vcsLocations: string[] = []
const calls = createFetch(async (url) => {
if (url.pathname === "/api/location") {
const requested = url.searchParams.get("location[directory]") ?? directory
locations.push(requested)
return json({
directory: requested,
project: { id: "project", directory: requested, canonical: directory },
})
}
if (url.pathname === "/api/vcs") {
const requested = url.searchParams.get("location[directory]") ?? directory
vcsLocations.push(requested)
return json({
location: { directory: requested },
data: { branch: { current: "main", default: "main" } },
})
}
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
@@ -80,7 +55,7 @@ async function renderSessionTabs(
id: sessionID,
title: sessionID === initialSessionID ? options?.title : undefined,
projectID: "project",
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
@@ -132,8 +107,6 @@ async function renderSessionTabs(
route,
data,
sessions,
locations,
vcsLocations,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
async destroy() {
@@ -164,22 +137,6 @@ test("loads persisted tab metadata concurrently on connect", async () => {
}
})
test("loads VCS metadata for each persisted tab location", async () => {
const other = `${directory}/other-worktree`
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionDirectories: { second: other },
})
try {
await wait(() => setup.locations.includes(other))
await wait(() => setup.vcsLocations.includes(other))
} finally {
await setup.destroy()
}
})
test("stores session tabs for the current working directory by default", async () => {
const setup = await renderSessionTabs("first")
+26 -3
View File
@@ -3,13 +3,23 @@ import { saveDraft, takeDraft } from "../../src/component/prompt/draft-stash"
import { emptyPrompt } from "../../src/prompt/history"
// The Prompt component stashes an unsent draft in onCleanup and takes it back
// in onMount across route remounts, keyed by sessionID or undefined for home.
// in onMount across route remounts. The key it uses is undefined by default
// (one global slot that follows focus across tabs) and the tab identity
// (sessionID, or "home") when the tab_drafts experiment is on.
function draft(text: string, cursor = text.length) {
return { prompt: { ...emptyPrompt(), text }, cursor }
}
describe("prompt draft stash", () => {
test("global slot follows focus: any tab takes the last stashed draft", () => {
const entry = draft("follow me")
saveDraft(undefined, entry)
expect(takeDraft(undefined)).toBe(entry)
// Consumed on take, so a remount never restores a stale copy.
expect(takeDraft(undefined)).toBeUndefined()
})
test("tab-keyed drafts stay on the tab they were written in", () => {
const two = draft("notes for session two")
saveDraft("ses_two", two)
@@ -27,12 +37,25 @@ describe("prompt draft stash", () => {
const one = draft("DRAFT-ONE")
const home = draft("draft on home")
saveDraft("ses_one", one)
saveDraft(undefined, home)
saveDraft("home", home)
expect(takeDraft(undefined)).toBe(home)
expect(takeDraft("home")).toBe(home)
expect(takeDraft("ses_one")).toBe(one)
})
test("global and tab slots never leak into each other when the experiment toggles mid-draft", () => {
const global = draft("stashed before enabling tab_drafts")
const keyed = draft("stashed after enabling tab_drafts")
saveDraft(undefined, global)
saveDraft("ses_a", keyed)
// A keyed lookup must not surface the global draft on the wrong tab...
expect(takeDraft("ses_b")).toBeUndefined()
// ...and the global slot must not surface a tab's draft.
expect(takeDraft(undefined)).toBe(global)
expect(takeDraft("ses_a")).toBe(keyed)
})
test("a newer draft for the same slot replaces the older one", () => {
saveDraft("ses_a", draft("first"))
const second = draft("second")