Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline 4b58478b6f feat(tui): show current branch in location labels 2026-08-03 13:45:33 +00:00
Shoubhit Dash 8ce850e142 fix(ai): expose client service requirements (#40275) 2026-08-03 17:01:32 +05:30
25 changed files with 274 additions and 86 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
## Conventions
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
+32 -1
View File
@@ -3,8 +3,9 @@
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
```ts
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { LLM, LLMClient } from "@opencode-ai/ai"
import { RequestExecutor } from "@opencode-ai/ai/route"
import { OpenAI } from "@opencode-ai/ai/providers"
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
@@ -20,6 +21,10 @@ const program = Effect.gen(function* () {
const response = yield* LLMClient.generate(request)
console.log(response.text)
})
const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
```
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
@@ -200,6 +205,32 @@ The hosted result is represented as a provider-executed tool call and tool resul
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
## Testing
Use the deterministic test client from `@opencode-ai/ai/testing` to script provider-neutral responses and inspect
the requests sent by code under test:
```ts
import { Effect } from "effect"
import { TestLLM } from "@opencode-ai/ai/testing"
const testLLM = TestLLM.layer({
fallback: TestLLM.text("Hello from the test model", "text-1"),
})
// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
const programWithTestClient = Effect.gen(function* () {
const result = yield* program
const test = yield* TestLLM.Service
console.log(test.requests)
return result
}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
```
`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
available on the yielded `TestLLM.Service`.
## Caching
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
+2 -2
View File
@@ -15,11 +15,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
): Effect.Effect<ImageResponse, AIError> =>
): Effect.Effect<ImageResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request)
}) as Effect.Effect<ImageResponse, AIError>
})
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
+3 -3
View File
@@ -1,5 +1,5 @@
import { Effect, JsonSchema, Schema } from "effect"
import { LLMClient } from "./route/client"
import { LLMClient, Service } from "./route/client"
import {
GenerationOptions,
HttpOptions,
@@ -151,10 +151,10 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
*/
export function generateObject<const SelectedLanguageModel extends LanguageModel, S extends ToolSchema<any>>(
options: GenerateObjectOptions<S, SelectedLanguageModel>,
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError>
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError, Service>
export function generateObject<const SelectedLanguageModel extends LanguageModel>(
options: GenerateObjectDynamicOptions<SelectedLanguageModel>,
): Effect.Effect<GenerateObjectResponse<unknown>, AIError>
): Effect.Effect<GenerateObjectResponse<unknown>, AIError, Service>
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
if ("schema" in options) {
const { schema, ...rest } = options
+4 -4
View File
@@ -422,18 +422,18 @@ const generateWith = (stream: Interface["stream"]) =>
)
})
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError> {
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
return Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request, options)
}),
) as Stream.Stream<LLMEvent, AIError>
)
}
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError> {
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError, Service> {
return Effect.gen(function* () {
return yield* (yield* Service).generate(request, options)
}) as Effect.Effect<LLMResponse, AIError>
})
}
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
+10
View File
@@ -1,5 +1,7 @@
import { Effect } from "effect"
import {
Image,
ImageClient,
ImageInput,
ImageModel,
type ImageModelOptions,
@@ -7,8 +9,13 @@ import {
type ImageRequestFor,
type ImageRoute,
} from "../src"
import type { Service } from "../src/image-client"
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
type Equal<A, B> = [A, B] extends [B, A] ? true : false
type Assert<T extends true> = T
type GoogleLikeOptions = {
readonly aspectRatio?: "1:1" | "16:9"
readonly imageSize?: "1K" | "2K"
@@ -146,6 +153,9 @@ const request = Image.request({
})
const typedRequest: ImageRequestFor<GoogleLikeOptions> = request
void typedRequest
const generated = ImageClient.generate(request)
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, Service>>
void (true satisfies GenerateRequirements)
// @ts-expect-error Image requests no longer expose a common count option.
Image.generate({ model: openai, prompt: "A lighthouse", count: 2 })
+32 -4
View File
@@ -1,5 +1,11 @@
import { Schema } from "effect"
import { LLM, type LanguageModel, type LanguageModelProviderOptions, type ProviderOptions } from "../src"
import { Effect, Schema, Stream } from "effect"
import {
LLM,
type LLMClientService,
type LanguageModel,
type LanguageModelProviderOptions,
type ProviderOptions,
} from "../src"
import { OpenAIChat } from "../src/protocols"
interface ExampleOptions {
@@ -15,9 +21,19 @@ const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://example.com/v1" } })
.model<ExampleProviderOptions>({ id: "example" })
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
type StreamRequirements<T> = T extends Stream.Stream<infer _A, infer _E, infer R> ? R : never
type Equal<A, B> = [A, B] extends [B, A] ? true : false
type Assert<T extends true> = T
LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } })
LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } })
const generated = LLM.generate(LLM.request({ model, prompt: "Hello" }))
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, LLMClientService>>
const streamed = LLM.stream(LLM.request({ model, prompt: "Hello" }))
type StreamClientRequirements = Assert<Equal<StreamRequirements<typeof streamed>, LLMClientService>>
LLM.request({
model,
prompt: "Hello",
@@ -25,12 +41,20 @@ LLM.request({
providerOptions: { example: { mode: "slow" } },
})
LLM.generateObject({
const generatedObject = LLM.generateObject({
model,
prompt: "Hello",
schema: Schema.Struct({ answer: Schema.String }),
providerOptions: { example: { mode: "thorough" } },
})
type GenerateObjectRequirements = Assert<Equal<Requirements<typeof generatedObject>, LLMClientService>>
const generatedDynamicObject = LLM.generateObject({
model,
prompt: "Hello",
jsonSchema: { type: "object" },
})
type GenerateDynamicObjectRequirements = Assert<Equal<Requirements<typeof generatedDynamicObject>, LLMClientService>>
LLM.generateObject({
model,
@@ -44,4 +68,8 @@ declare const generic: LanguageModel
LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } })
const options: LanguageModelProviderOptions<typeof model> = { example: { mode: "fast" } }
void options
void (options satisfies LanguageModelProviderOptions<typeof model>)
void (true satisfies GenerateRequirements)
void (true satisfies StreamClientRequirements)
void (true satisfies GenerateObjectRequirements)
void (true satisfies GenerateDynamicObjectRequirements)
+11 -4
View File
@@ -1502,18 +1502,25 @@ export interface ProjectCopyApi<E = never> {
export type Endpoint25_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint25_2Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
readonly context?: number | undefined
}
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly status: VcsStatusOperation<E>
readonly diff: VcsDiffOperation<E>
}
+14 -3
View File
@@ -210,6 +210,8 @@ import type {
Endpoint25_0Output,
Endpoint25_1Input,
Endpoint25_1Output,
Endpoint25_2Input,
Endpoint25_2Output,
Endpoint26_0Output,
Endpoint26_1Input,
Endpoint26_1Output,
@@ -1182,17 +1184,26 @@ const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
preserveEffect<Endpoint25_0Output>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
preserveEffect<Endpoint25_2Output>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) })
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint25_0(raw),
status: Endpoint25_1(raw),
diff: Endpoint25_2(raw),
})
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
@@ -202,6 +202,8 @@ import type {
ProjectCopyRemoveOutput,
ProjectCopyRefreshInput,
ProjectCopyRefreshOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
VcsStatusOutput,
VcsDiffInput,
@@ -1702,6 +1704,18 @@ export function make(options: ClientOptions) {
),
},
vcs: {
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
{
method: "GET",
path: `/api/vcs`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
request<VcsStatusOutput>(
{
@@ -530,6 +530,8 @@ export type ReferenceGitSource = {
export type ProjectCopyCopy = { directory: string }
export type VcsInfo = { branch?: string }
export type VcsFileStatus = {
file: string
additions: number
@@ -4902,6 +4904,17 @@ export type ProjectCopyRefreshInput = {
export type ProjectCopyRefreshOutput = void
export type VcsGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type VcsGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: VcsInfo
}
export type VcsStatusInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+28 -2
View File
@@ -1,14 +1,17 @@
export * as Vcs from "./vcs"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Layer, Ref, Stream } from "effect"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { AppProcess } from "@opencode-ai/util/process"
import { VcsGit } from "./vcs/git"
import { VcsHg } from "./vcs/hg"
import { Bus } from "./bus"
export { FileStatus, Mode }
@@ -17,6 +20,7 @@ export interface DiffOptions {
}
export interface Interface {
readonly branch: () => Effect.Effect<string | undefined>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
}
@@ -38,8 +42,30 @@ const layer = Layer.effect(
const proc = yield* AppProcess.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const impl = adapter(proc, fs, location)
const branch = yield* Ref.make(impl ? yield* impl.branch() : undefined)
if (impl && location.vcs?.type === "git") {
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => event.data.file.endsWith("HEAD")),
Stream.runForEach(() =>
Effect.gen(function* () {
const next = yield* impl.branch()
if (next === (yield* Ref.get(branch))) return
yield* Ref.set(branch, next)
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next }, {
location: { directory: location.directory, workspaceID: location.workspaceID },
})
}),
),
Effect.forkScoped({ startImmediately: true }),
)
}
return Service.of({
branch: Effect.fn("Vcs.branch")(function* () {
if (!impl) return
return yield* impl.branch()
}),
status: Effect.fn("Vcs.status")(function* () {
if (!impl) return []
return yield* impl.status()
@@ -55,5 +81,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [AppProcess.node, FSUtil.node, Location.node],
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
})
+3
View File
@@ -20,6 +20,9 @@ export function make(proc: AppProcess.Interface, input: { directory: string; wor
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
return {
branch: Effect.fn("VcsGit.branch")(function* () {
return yield* ctx.git.branch(ctx.directory)
}),
status: Effect.fn("VcsGit.status")(function* () {
const git = ctx.git
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
+3
View File
@@ -73,6 +73,9 @@ export function make(
})
return {
branch: Effect.fn("VcsHg.branch")(function* () {
return yield* hg.branch()
}),
status: Effect.fn("VcsHg.status")(function* () {
const [items, batch] = yield* Effect.all(
// Zero-context patches are enough to count changed lines.
+3
View File
@@ -53,6 +53,7 @@ describe("Vcs", () => {
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
expect(yield* vcs.branch()).toBeUndefined()
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
expect(yield* vcs.diff("branch")).toEqual([])
@@ -155,6 +156,7 @@ describe("Vcs", () => {
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
expect(yield* vcs.branch()).toBe("main")
expect(yield* vcs.diff("branch")).toEqual([])
yield* Effect.promise(async () => {
@@ -162,6 +164,7 @@ describe("Vcs", () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\ntwo\n")
await commitAll(directory, "feature change")
})
expect(yield* vcs.branch()).toBe("feature")
const diff = yield* vcs.diff("branch")
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
{ file: "file.txt", status: "modified" },
+6
View File
@@ -19,6 +19,7 @@ import type {
SessionPendingInfo,
ShellInfo,
SkillInfo,
VcsInfo,
} from "@opencode-ai/client"
import type { ResolvedTheme } from "@opencode-ai/theme/tui"
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"
@@ -113,6 +114,11 @@ export interface Data {
default(): LocationRef
sync(location?: LocationRef): Promise<void>
invalidate(location?: LocationRef): void
readonly vcs: {
get(location?: LocationRef): VcsInfo | undefined
sync(location?: LocationRef): Promise<void>
invalidate(location?: LocationRef): void
}
readonly agent: LocationCollection<AgentInfo>
readonly command: LocationCollection<CommandInfo>
readonly integration: LocationCollection<IntegrationInfo>
+14
View File
@@ -13,6 +13,20 @@ const DiffQuery = Schema.Struct({
})
export const VcsGroup = HttpApiGroup.make("server.vcs")
.add(
HttpApiEndpoint.get("vcs.get", "/api/vcs", {
query: LocationQuery,
success: Location.response(Vcs.Info),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.vcs.get",
summary: "VCS information",
description: "Get version control information for the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("vcs.status", "/api/vcs/status", {
query: LocationQuery,
+6 -1
View File
@@ -1,7 +1,12 @@
export * as Vcs from "./vcs.js"
import { Schema } from "effect"
import { NonNegativeInt } from "./schema.js"
import { NonNegativeInt, optional } from "./schema.js"
export const Info = Schema.Struct({
branch: optional(Schema.String),
}).annotate({ identifier: "Vcs.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" })
export type Mode = typeof Mode.Type
+8
View File
@@ -7,6 +7,14 @@ import { response } from "../location"
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
Effect.gen(function* () {
return handlers
.handle("vcs.get", () =>
response(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return { branch: yield* vcs.branch() }
}),
),
)
.handle("vcs.status", () =>
response(
Effect.gen(function* () {
+8 -3
View File
@@ -1330,11 +1330,16 @@ export function Prompt(props: PromptProps) {
if (!props.sessionID) {
// No session yet: show where the next session will be created.
const directory = currentLocation.ref?.directory ?? data.location.default().directory
return abbreviateHome(directory, paths.home)
const branch = data.location.vcs.get(currentLocation.ref)?.branch
const label = abbreviateHome(directory, paths.home)
return branch ? label + ":" + branch : label
}
if (status() !== "idle") return
const directory = data.session.get(props.sessionID)?.location.directory
return directory ? abbreviateHome(directory, paths.home) : undefined
const ref = data.session.get(props.sessionID)?.location
if (!ref) return
const label = abbreviateHome(ref.directory, paths.home)
const branch = data.location.vcs.get(ref)?.branch
return branch ? label + ":" + branch : label
})
const spinnerDef = createMemo(() => {
+33
View File
@@ -28,6 +28,7 @@ import type {
ShellInfo,
SkillInfo,
OpenCodeEvent,
VcsInfo,
WebSearchProvider,
} from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin/tui"
@@ -49,6 +50,7 @@ type ShellWithLocation = ShellInfo & { readonly location: LocationRef }
type LocationData = {
info?: LocationGetOutput
vcs?: VcsInfo
agent?: AgentInfo[]
command?: CommandInfo[]
integration?: IntegrationInfo[]
@@ -905,6 +907,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.mcp.resource.invalidate(event.location)
void result.location.mcp.resource.sync(event.location)
break
case "vcs.branch.updated": {
const ref = event.location ?? defaultLocation()
const key = locationKey(ref)
setStore("location", key, {
...store.location[key],
vcs: { branch: event.data.branch },
})
break
}
}
}
@@ -1139,6 +1150,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
default() {
return defaultLocation()
},
vcs: {
get(ref?: LocationRef) {
return store.location[locationKey(ref ?? defaultLocation())]?.vcs
},
sync(ref?: LocationRef) {
const location = ref ?? defaultLocation()
const id = locationKey(location)
return sync.run(`location.vcs:${id}`, async () => {
const response = await client.api.vcs.get({ location: locationQuery(location) })
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
vcs: response.data,
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`)
},
},
async sync(ref?: LocationRef) {
const current = ref ?? defaultLocation()
await sync.run(`location:${locationKey(current)}`, async () => {
@@ -1161,6 +1192,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.provider.sync(location),
result.location.reference.sync(location),
result.location.skill.sync(location),
result.location.vcs.sync(location),
result.shell.sync(location),
result.session.form.sync("global", location),
])
@@ -1177,6 +1209,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.provider.invalidate(location)
result.location.reference.invalidate(location)
result.location.skill.invalidate(location)
result.location.vcs.invalidate(location)
result.shell.invalidate(location)
result.session.form.invalidate("global", location)
},
+14 -32
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
@@ -126,40 +126,22 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
}
createEffect(
on(
[
() => (enabled() && route.data.type === "session" ? route.data.sessionID : undefined),
() => config.tabs?.scope,
() => paths.cwd,
],
([routed]) => {
if (!routed || routed === "dummy") return
const sessionID = root(routed)
history = recordSessionTabHistory(history, sessionID)
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, {
sessionID,
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
delete draft.unread[sessionID]
})
},
),
)
createEffect(() => {
if (!enabled() || route.data.type !== "session" || route.data.sessionID === "dummy") return
if (!enabled()) return
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
const tab = state().tabs.find((tab) => tab.sessionID === sessionID)
if (!tab) return
const nextTitle = title(sessionID, tab.title)
if ((!nextTitle || nextTitle === tab.title) && !state().unread[sessionID]) return
history = recordSessionTabHistory(history, sessionID)
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const tabs = openSessionTab(state().tabs, {
sessionID,
title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
if (tabs === state().tabs && !state().unread[sessionID]) return
update((draft) => {
const tab = draft.tabs.find((tab) => tab.sessionID === sessionID)
if (!tab) return
draft.tabs = openSessionTab(draft.tabs, { sessionID, title: title(sessionID, tab.title) })
draft.tabs = openSessionTab(draft.tabs, {
sessionID,
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
delete draft.unread[sessionID]
})
})
@@ -6,8 +6,14 @@ function View(props: { context: Plugin.Context }) {
const directory = createMemo(() =>
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
)
const value = createMemo(() => {
const path = directory()
if (!path) return
const branch = props.context.data.location.vcs.get(props.context.location)?.branch
return branch ? path + ":" + branch : path
})
return (
<Show when={directory()}>
<Show when={value()}>
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
</Show>
)
@@ -170,31 +170,6 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
}
})
test("closing a tab is not undone by another TUI viewing the same session", async () => {
const state = stateDir("opencode-session-tabs-shared-close-")
const first = await renderSessionTabs("shared", { state })
const second = await renderSessionTabs("shared", { state })
try {
await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "shared"))
await wait(() => second.tabs.tabs().some((tab) => tab.sessionID === "shared"))
first.tabs.close()
await wait(() => first.route.data.type === "home")
await wait(() => !second.tabs.tabs().some((tab) => tab.sessionID === "shared"))
await Bun.sleep(50)
expect(first.tabs.tabs().some((tab) => tab.sessionID === "shared")).toBe(false)
second.route.navigate({ type: "home" })
await wait(() => second.route.data.type === "home")
second.route.navigate({ type: "session", sessionID: "shared" })
await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "shared"))
} finally {
first.destroy()
second.destroy()
}
})
test("user prompt admissions pulse an already-busy background tab", async () => {
const setup = await renderSessionTabs("background")
const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
+5
View File
@@ -127,6 +127,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
})
if (url.pathname === "/api/reference")
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
if (url.pathname === "/api/vcs")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
data: { branch: "main" },
})
if (url.pathname === "/api/websearch/provider") {
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
}