From d9555f138b4207e3f6f1fc6ce91f3dbbb67c9085 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 16:51:11 +0530 Subject: [PATCH 01/51] refactor(ai): internalize request compilation (#39132) --- packages/ai/AGENTS.md | 6 +- packages/ai/README.md | 1 - packages/ai/example/call-sites.md | 2 +- packages/ai/example/tutorial.ts | 34 +------- packages/ai/src/route/client.ts | 31 ++------ packages/ai/src/schema/events.ts | 26 +----- packages/ai/src/testing.ts | 1 - packages/ai/test/adapter.test.ts | 6 +- packages/ai/test/cache-policy.test.ts | 27 ++++--- .../test/{prepare.test.ts => compile.test.ts} | 13 ++- .../test/provider/anthropic-messages.test.ts | 64 ++++++++------- .../ai/test/provider/bedrock-converse.test.ts | 53 ++++++------- packages/ai/test/provider/cloudflare.test.ts | 10 +-- packages/ai/test/provider/gemini.test.ts | 27 ++++--- .../ai/test/provider/google-vertex.test.ts | 3 +- .../openai-chat-reasoning.recorded.test.ts | 5 +- packages/ai/test/provider/openai-chat.test.ts | 77 +++++++----------- .../provider/openai-compatible-chat.test.ts | 7 +- .../openai-compatible-responses.test.ts | 9 ++- .../ai/test/provider/openai-responses.test.ts | 79 +++++++++---------- packages/ai/test/provider/openrouter.test.ts | 13 +-- .../ai/test/tool-schema-projection.test.ts | 5 +- packages/core/test/aisdk.test.ts | 37 +++------ packages/core/test/model-resolver.test.ts | 4 +- packages/core/test/session-compact.test.ts | 1 - packages/core/test/session-compaction.test.ts | 1 - packages/core/test/session-generate.test.ts | 1 - packages/core/test/session-title.test.ts | 1 - 28 files changed, 216 insertions(+), 328 deletions(-) rename packages/ai/test/{prepare.test.ts => compile.test.ts} (93%) diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md index 6c35cafc34..ad4a625385 100644 --- a/packages/ai/AGENTS.md +++ b/packages/ai/AGENTS.md @@ -46,7 +46,7 @@ const response = yield * LLMClient.generate(request) `LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`. -Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare(...)` returns a `PreparedRequestOf`). The runtime body is identical; the generic is a type-level assertion. +Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code. @@ -138,13 +138,13 @@ packages/ai/src/ ids.ts branded IDs, literal types, ProviderMetadata options.ts Generation/Provider/Http options, Limits, Model, cache policy messages.ts content parts, Message, ToolDefinition, LLMRequest - events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse + events.ts Usage, individual events, LLMEvent, LLMResponse errors.ts error reasons, LLMError, ToolFailure index.ts barrel llm.ts request constructors and convenience helpers route/ index.ts @opencode-ai/ai/route advanced barrel - client.ts Route.make + LLMClient.prepare/stream/generate + client.ts Route.make + LLMClient.stream/generate executor.ts RequestExecutor service + transport error mapping protocol.ts Protocol type + Protocol.make endpoint.ts Endpoint type + Endpoint.path diff --git a/packages/ai/README.md b/packages/ai/README.md index 377e09306f..6d89fa317b 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -196,7 +196,6 @@ The hosted result is represented as a provider-executed tool call and tool resul - **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use. - **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model. - **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model. -- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing. - **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams. - **`Image.generate({...})`** — generate images through a provider-neutral image request and response model. - **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`. diff --git a/packages/ai/example/call-sites.md b/packages/ai/example/call-sites.md index 0b33d28c48..397e0cc09f 100644 --- a/packages/ai/example/call-sites.md +++ b/packages/ai/example/call-sites.md @@ -568,7 +568,7 @@ App boundary = explicit durable-config -> typed-provider call calling `.model(...)`. - [x] Remove request-shaping defaults from `Model`; selected models now carry only id, provider, and configured route while defaults live on routes or requests. -- [x] Rework `LLMClient.prepare` / `stream` / `generate` to read +- [x] Rework `LLMClient.stream` / `generate` to read `request.model.route` directly instead of calling `registeredRoute(...)`. - [x] Remove `Route.make(...)` global registration from the normal execution path; keep route ids only as diagnostics/provider API labels. diff --git a/packages/ai/example/tutorial.ts b/packages/ai/example/tutorial.ts index 7ee4abb146..3924a57dd2 100644 --- a/packages/ai/example/tutorial.ts +++ b/packages/ai/example/tutorial.ts @@ -50,18 +50,6 @@ const request = LLM.request({ }, }) -// `http` is intentionally not needed for normal calls. This shows the shape for -// newly released provider fields before they deserve a typed provider option. -const rawOverlayExample = LLM.request({ - model, - prompt: "Show the final HTTP overlay shape.", - http: { - body: { metadata: { example: "tutorial" } }, - headers: { "x-opencode-tutorial": "1" }, - query: { debug: "1" }, - }, -}) - // 3. `generate` sends the request and collects the event stream into one // response object. `response.text` is the collected text output. const generateOnce = Effect.gen(function* () { @@ -222,33 +210,15 @@ const FakeEcho = { }), } -// `LLMClient.prepare` is the lower-level inspection hook: it compiles through -// body conversion, validation, endpoint, auth, and HTTP construction without -// sending anything over the network. -const inspectFakeProvider = Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( - LLM.request({ - model: FakeEcho.configure().model("tiny-echo"), - prompt: "Show me the provider pipeline.", - }), - ) - - console.log("\n== fake provider prepare ==") - console.log("route:", prepared.route) - console.log("body:", Formatter.formatJson(prepared.body, { space: 2 })) -}) - // Provide the LLM runtime and the HTTP request executor once. Keep one path -// enabled at a time so the tutorial can demonstrate generate, prepare, stream, -// or tool-loop behavior without spending tokens on every example. +// enabled at a time so the tutorial can demonstrate generate, stream, or +// tool-loop behavior without spending tokens on every example. const requestExecutorLayer = RequestExecutor.fetchLayer const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps)) const program = Effect.gen(function* () { // yield* generateOnce - // yield* inspectFakeProvider - // yield* LLMClient.prepare(rawOverlayExample).pipe(Effect.andThen((prepared) => Effect.sync(() => console.log(prepared.body)))) // yield* streamText // yield* generateStructuredObject // yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object)))) diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 067292329b..048f82f554 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -10,7 +10,7 @@ import { WebSocketExecutor } from "./transport" import type { Protocol } from "./protocol" import { applyCachePolicy } from "../cache-policy" import * as ProviderShared from "../protocols/shared" -import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema" +import type { LLMError, ProtocolID, ProviderOptions } from "../schema" import { GenerationOptions, HttpOptions, @@ -20,7 +20,6 @@ import { ModelLimits, LLMError as LLMErrorClass, LLMEvent, - PreparedRequest, ProviderID, mergeGenerationOptions, mergeHttpOptions, @@ -142,17 +141,6 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => { } export interface Interface { - /** - * Compile a request through protocol body construction, validation, and HTTP - * preparation without sending it. Returns the prepared request including the - * provider-native body. - * - * Pass a `Body` type argument to statically expose the route's body - * shape (e.g. `prepare(...)`) — the runtime body is - * identical, so this is a type-level assertion the caller makes about which - * route the request will resolve to. - */ - readonly prepare: (request: LLMRequest) => Effect.Effect, LLMError> readonly stream: StreamMethod readonly generate: GenerateMethod } @@ -370,9 +358,6 @@ export function make( }) } -// `compile` is the important boundary: it turns a common `LLMRequest` into a -// validated provider body plus transport-private prepared data, but does not -// execute transport. const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { const resolved = applyCachePolicy(resolveRequestOptions(request)) const route = resolved.model.route @@ -390,17 +375,17 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { } }) -const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) { +/** @internal Test-only projection of the execution compiler; not exported from package barrels. */ +export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request: LLMRequest) { const compiled = yield* compile(request) - - return new PreparedRequest({ + return { id: compiled.request.id ?? "request", route: compiled.route.id, protocol: compiled.route.protocol, model: compiled.request.model, body: compiled.body, metadata: { transport: compiled.route.transport.id }, - }) + } }) const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) => @@ -422,9 +407,6 @@ const generateWith = (stream: Interface["stream"]) => ) }) -export const prepare = (request: LLMRequest) => - prepareWith(request) as Effect.Effect, LLMError> - export function stream(request: LLMRequest): Stream.Stream { return Stream.unwrap( Effect.gen(function* () { @@ -453,7 +435,7 @@ export const layer: Layer.Layer = Layer http: yield* RequestExecutor.Service, webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)), }) - return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) }) + return Service.of({ stream, generate: generateWith(stream) }) }), ) @@ -462,7 +444,6 @@ export const Route = { make } as const export const LLMClient = { Service, layer, - prepare, stream, generate, } as const diff --git a/packages/ai/src/schema/events.ts b/packages/ai/src/schema/events.ts index 654a892859..e1b9251c02 100644 --- a/packages/ai/src/schema/events.ts +++ b/packages/ai/src/schema/events.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" -import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" -import { ModelSchema } from "./options" +import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids" import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages" import { ProviderFailureClassification } from "./errors" @@ -314,29 +313,6 @@ export const LLMEvent = Object.assign(llmEventTagged, { }) export type LLMEvent = Schema.Schema.Type -export class PreparedRequest extends Schema.Class("LLM.PreparedRequest")({ - id: Schema.String, - route: RouteID, - protocol: ProtocolID, - model: ModelSchema, - body: Schema.Unknown, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), -}) {} - -/** - * A `PreparedRequest` whose `body` is typed as `Body`. Use with the generic - * on `LLMClient.prepare(...)` when the caller knows which route their - * request will resolve to and wants its native shape statically exposed - * (debug UIs, request previews, plan rendering). - * - * The runtime body is identical — the route still emits `body: unknown` — so - * this is a type-level assertion the caller makes about what they expect to - * find. The prepare runtime does not validate the assertion. - */ -export type PreparedRequestOf = Omit & { - readonly body: Body -} - const responseText = (events: ReadonlyArray) => events .filter(LLMEvent.is.textDelta) diff --git a/packages/ai/src/testing.ts b/packages/ai/src/testing.ts index 69619b96e5..dbddb8d988 100644 --- a/packages/ai/src/testing.ts +++ b/packages/ai/src/testing.ts @@ -99,7 +99,6 @@ export const layer = (options: LayerOptions = {}) => ) }) as LLMClientShape["stream"] const client = LLMClient.Service.of({ - prepare: () => Effect.die("TestLLM does not prepare provider-native requests"), stream, generate: (request) => stream(request).pipe( diff --git a/packages/ai/test/adapter.test.ts b/packages/ai/test/adapter.test.ts index b2b180a2b2..9409d04a17 100644 --- a/packages/ai/test/adapter.test.ts +++ b/packages/ai/test/adapter.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" import { LLM, LLMRequest, LLMResponse } from "../src" import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route" +import { compileRequest } from "../src/route/client" import { Model } from "../src/schema" import { testEffect } from "./lib/effect" import { dynamicResponse } from "./lib/http" @@ -139,8 +140,7 @@ describe("llm route", () => { it.effect("selects routes by model route value", () => Effect.gen(function* () { - const llm = yield* LLMClient.Service - const prepared = yield* llm.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }), ) @@ -173,7 +173,7 @@ describe("llm route", () => { framing: fakeFraming, }) - const prepared = yield* (yield* LLMClient.Service).prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }), ) diff --git a/packages/ai/test/cache-policy.test.ts b/packages/ai/test/cache-policy.test.ts index 8e2535b54b..a862b8b65d 100644 --- a/packages/ai/test/cache-policy.test.ts +++ b/packages/ai/test/cache-policy.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { CacheHint, LLM, Message } from "../src" -import { Auth, LLMClient } from "../src/route" +import { Auth } from "../src/route" +import { compileRequest } from "../src/route/client" import { AmazonBedrock } from "../src/providers" import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as Gemini from "../src/protocols/gemini" @@ -31,7 +32,7 @@ const geminiModel = Gemini.route describe("applyCachePolicy", () => { it.effect("undefined cache resolves to 'auto' (the recommended default)", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, system: "You are concise.", @@ -50,7 +51,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, system: [ @@ -87,7 +88,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: openaiModel, system: "Sys", @@ -106,7 +107,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: geminiModel, system: "Sys", @@ -123,7 +124,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: bedrockModel, system: [ @@ -157,7 +158,7 @@ describe("applyCachePolicy", () => { it.effect("'none' disables auto placement even when manual hints exist", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, system: "Sys", @@ -176,7 +177,7 @@ describe("applyCachePolicy", () => { it.effect("granular object form: tools-only marks just tools", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, system: "Sys", @@ -195,7 +196,7 @@ describe("applyCachePolicy", () => { it.effect("auto policy preserves manual CacheHints on other parts", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, system: [ @@ -241,7 +242,7 @@ describe("applyCachePolicy", () => { expect("cache" in tail ? tail.cache : undefined).toBeUndefined() expect(applyCachePolicy(applied)).toBe(applied) - const prepared = yield* LLMClient.prepare(request) + const prepared = yield* compileRequest(request) const body = prepared.body as { tools: Array<{ cache_control?: unknown }> @@ -261,7 +262,7 @@ describe("applyCachePolicy", () => { it.effect("ttlSeconds in the policy flows through to wire markers", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, system: "Sys", @@ -278,7 +279,7 @@ describe("applyCachePolicy", () => { it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")], @@ -296,7 +297,7 @@ describe("applyCachePolicy", () => { it.effect("'latest-assistant' marks the last assistant message", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")], diff --git a/packages/ai/test/prepare.test.ts b/packages/ai/test/compile.test.ts similarity index 93% rename from packages/ai/test/prepare.test.ts rename to packages/ai/test/compile.test.ts index 6923c5a678..b64b173b0e 100644 --- a/packages/ai/test/prepare.test.ts +++ b/packages/ai/test/compile.test.ts @@ -4,6 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http" import { LLM, mergeProviderOptions } from "../src" import { AnthropicMessages, OpenAIChat } from "../src/protocols" import { Auth, LLMClient } from "../src/route" +import { compileRequest } from "../src/route/client" import { it } from "./lib/effect" import { dynamicResponse } from "./lib/http" import { deltaChunk } from "./lib/openai-chunks" @@ -44,7 +45,7 @@ describe("request option precedence", () => { }) }) - it.effect("prepares bodies with route defaults, model defaults, and call options in order", () => + it.effect("compiles bodies with route defaults, model defaults, and call options in order", () => Effect.gen(function* () { const route = OpenAIChat.route.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, @@ -59,7 +60,7 @@ describe("request option precedence", () => { providerOptions: { openai: { reasoningEffort: "medium" } }, }, }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "Say hello.", @@ -141,7 +142,7 @@ describe("request option precedence", () => { const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "gpt-4o-mini" }) - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, prompt: "Say hello.", @@ -164,10 +165,8 @@ describe("request option precedence", () => { limits: { output: 128 }, }) const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } }) - const withoutMaxTokens = yield* LLMClient.prepare( - LLM.request({ model, prompt: "Say hello.", cache: "none" }), - ) - const withMaxTokens = yield* LLMClient.prepare( + const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" })) + const withMaxTokens = yield* compileRequest( LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }), ) diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index af1b3d99c8..920978258c 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import { HttpClientRequest } from "effect/unstable/http" import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src" import { Auth, LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios" import { it } from "../lib/effect" @@ -44,7 +45,7 @@ const expectToolResult = (body: AnthropicMessages.AnthropicMessagesBody): Anthro describe("Anthropic Messages route", () => { it.effect("prepares Anthropic Messages target", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(request) + const prepared = yield* compileRequest(request) expect(prepared.body).toEqual({ model: "claude-sonnet-4-5", @@ -59,7 +60,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers adaptive thinking settings with effort", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { providerOptions: { anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, @@ -76,17 +77,17 @@ describe("Anthropic Messages route", () => { it.effect("normalizes enabled and disabled thinking settings", () => Effect.gen(function* () { - const enabled = yield* LLMClient.prepare( + const enabled = yield* compileRequest( LLMRequest.update(request, { providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } }, }), ) - const legacy = yield* LLMClient.prepare( + const legacy = yield* compileRequest( LLMRequest.update(request, { providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } }, }), ) - const disabled = yield* LLMClient.prepare( + const disabled = yield* compileRequest( LLMRequest.update(request, { providerOptions: { anthropic: { thinking: { type: "disabled" } } }, }), @@ -100,7 +101,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects enabled thinking without a budget", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLMRequest.update(request, { providerOptions: { anthropic: { thinking: { type: "enabled" } } }, }), @@ -112,7 +113,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: opus48, messages: [ @@ -137,7 +138,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -164,7 +165,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects non-text chronological system update content before send", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model: opus48, messages: [ @@ -181,7 +182,7 @@ describe("Anthropic Messages route", () => { it.effect("falls back for unsupported native chronological system update placement", () => Effect.gen(function* () { expect( - (yield* LLMClient.prepare( + (yield* compileRequest( LLM.request({ model: opus48, messages: [Message.assistant("Plain."), Message.system("After plain assistant.")], @@ -196,12 +197,11 @@ describe("Anthropic Messages route", () => { }, ]) expect( - (yield* LLMClient.prepare( - LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }), - )).body.messages, + (yield* compileRequest(LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }))) + .body.messages, ).toEqual([{ role: "user", content: [{ type: "text", text: "\nFirst.\n" }] }]) expect( - (yield* LLMClient.prepare( + (yield* compileRequest( LLM.request({ model: opus48, messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")], @@ -223,7 +223,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects a system update between a local tool call and its result", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model: opus48, messages: [ @@ -242,7 +242,7 @@ describe("Anthropic Messages route", () => { it.effect("prepares tool call and tool result messages", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result", model, @@ -273,7 +273,7 @@ describe("Anthropic Messages route", () => { it.effect("keeps tools and sends tool_choice none", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_choice_none", model, @@ -303,7 +303,7 @@ describe("Anthropic Messages route", () => { // not JSON-stringified into `tool_result.content`. it.effect("lowers media tool-result content as structured blocks", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result_image", model, @@ -335,7 +335,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers single-image tool-result content as a structured image block", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result_image_only", model, @@ -360,7 +360,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects unsupported media in tool-result content with a clear error", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_tool_result_unsupported_media", model, @@ -384,7 +384,7 @@ describe("Anthropic Messages route", () => { it.effect("prepares the composed native continuation request", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( continuationRequest({ id: "req_native_continuation_anthropic", model, @@ -428,7 +428,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers preserved Anthropic reasoning signature metadata", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -447,7 +447,7 @@ describe("Anthropic Messages route", () => { it.effect("round-trips redacted thinking as redacted_thinking blocks", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -628,9 +628,7 @@ describe("Anthropic Messages route", () => { { type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } }, ]) - const prepared = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message], cache: "none" }), - ) + const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" })) expect(prepared.body.messages).toEqual([ { role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] }, ]) @@ -773,7 +771,7 @@ describe("Anthropic Messages route", () => { ), ), ) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -1133,7 +1131,7 @@ describe("Anthropic Messages route", () => { it.effect("round-trips provider-executed assistant content into server tool blocks", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_round_trip", model, @@ -1184,7 +1182,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects round-trip for unknown server tool names", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_unknown_server_tool", model, @@ -1261,7 +1259,7 @@ describe("Anthropic Messages route", () => { it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) }, @@ -1277,7 +1275,7 @@ describe("Anthropic Messages route", () => { it.effect("emits cache_control on tool definitions and tool-result blocks", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, tools: [ @@ -1318,7 +1316,7 @@ describe("Anthropic Messages route", () => { it.effect("drops cache_control breakpoints past the 4-per-request cap", () => Effect.gen(function* () { const hint = new CacheHint({ type: "ephemeral" }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, system: [ @@ -1344,7 +1342,7 @@ describe("Anthropic Messages route", () => { it.effect("spends breakpoint budget on tools before system before messages", () => Effect.gen(function* () { const hint = new CacheHint({ type: "ephemeral" }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, tools: [ diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index 5cc8af1378..0bca4b9c5c 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -13,6 +13,7 @@ import { ToolDefinition, } from "../../src" import { LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import { AmazonBedrock } from "../../src/providers" import * as BedrockConverse from "../../src/protocols/bedrock-converse" import { it } from "../lib/effect" @@ -101,7 +102,7 @@ const baseRequest = LLM.request({ describe("Bedrock Converse route", () => { it.effect("prepares Converse target with system, inference config, and messages", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(baseRequest) + const prepared = yield* compileRequest(baseRequest) expect(prepared.body).toEqual({ modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0", @@ -114,7 +115,7 @@ describe("Bedrock Converse route", () => { it.effect("passes topK through additionalModelRequestFields as top_k", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(baseRequest, { generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }), }), @@ -129,14 +130,14 @@ describe("Bedrock Converse route", () => { it.effect("omits additionalModelRequestFields when topK is unset", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(baseRequest) + const prepared = yield* compileRequest(baseRequest) expect(prepared.body.additionalModelRequestFields).toBeUndefined() }), ) it.effect("lowers chronological system updates to wrapped user text in order", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")], @@ -153,7 +154,7 @@ describe("Bedrock Converse route", () => { it.effect("prepares tool config with toolSpec and toolChoice", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(baseRequest, { tools: [ ToolDefinition.make({ @@ -187,7 +188,7 @@ describe("Bedrock Converse route", () => { it.effect("keeps tools and omits the unsupported choice when tool choice is none", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(baseRequest, { tools: [ ToolDefinition.make({ @@ -217,7 +218,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers assistant tool-call + tool-result message history", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_history", model, @@ -256,7 +257,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers image content in tool-result messages", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_image", model, @@ -491,7 +492,7 @@ describe("Bedrock Converse route", () => { providerMetadata: { bedrock: { signature: "sig_1" } }, }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -546,9 +547,7 @@ describe("Bedrock Converse route", () => { }, ]) - const prepared = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message], cache: "none" }), - ) + const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" })) expect(prepared.body.messages).toEqual([ { role: "assistant", @@ -639,7 +638,7 @@ describe("Bedrock Converse route", () => { text: "", providerMetadata: { bedrock: { redactedData } }, }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -754,7 +753,7 @@ describe("Bedrock Converse route", () => { secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", }, }).model("anthropic.claude-3-5-sonnet-20240620-v1:0") - const prepared = yield* LLMClient.prepare(LLMRequest.update(baseRequest, { model: signed })) + const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed })) expect(prepared.route).toBe("bedrock-converse") expect(prepared.model).toBe(signed) @@ -764,7 +763,7 @@ describe("Bedrock Converse route", () => { it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () => Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_cache", model, @@ -796,7 +795,7 @@ describe("Bedrock Converse route", () => { it.effect("does not emit cachePoint when no cache hint is set", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(baseRequest) + const prepared = yield* compileRequest(baseRequest) expect(prepared.body).toMatchObject({ system: [{ text: "You are concise." }], messages: [{ role: "user", content: [{ text: "Say hello." }] }], @@ -806,7 +805,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers image media into Bedrock image blocks", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_image", model, @@ -843,7 +842,7 @@ describe("Bedrock Converse route", () => { it.effect("base64-encodes Uint8Array image bytes", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_image_bytes", model, @@ -865,7 +864,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers document media into Bedrock document blocks with format and name", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_doc", model, @@ -897,7 +896,7 @@ describe("Bedrock Converse route", () => { it.effect("requires names for document media", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })], @@ -910,7 +909,7 @@ describe("Bedrock Converse route", () => { it.effect("passes named document-only messages through for provider validation", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, cache: "none", @@ -936,7 +935,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers document media in tool results", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, cache: "none", @@ -988,7 +987,7 @@ describe("Bedrock Converse route", () => { it.effect("rejects unsupported image media types", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_bad_image", model, @@ -1002,7 +1001,7 @@ describe("Bedrock Converse route", () => { it.effect("rejects unsupported document media types", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_bad_doc", model, @@ -1017,7 +1016,7 @@ describe("Bedrock Converse route", () => { it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () => Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, system: [{ type: "text", text: "system", cache }], @@ -1034,7 +1033,7 @@ describe("Bedrock Converse route", () => { it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () => Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }], @@ -1066,7 +1065,7 @@ describe("Bedrock Converse route", () => { it.effect("drops cachePoint markers past the 4-per-request cap", () => Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, system: [ diff --git a/packages/ai/test/provider/cloudflare.test.ts b/packages/ai/test/provider/cloudflare.test.ts index 309f5db0ff..e09e321873 100644 --- a/packages/ai/test/provider/cloudflare.test.ts +++ b/packages/ai/test/provider/cloudflare.test.ts @@ -3,7 +3,7 @@ import { ConfigProvider, Effect, Schema } from "effect" import { HttpClientRequest } from "effect/unstable/http" import { LLM, LLMEvent } from "../../src" import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare" -import { LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import { it } from "../lib/effect" import { dynamicResponse } from "../lib/http" import { sseEvents } from "../lib/sse" @@ -34,7 +34,7 @@ describe("Cloudflare", () => { }) expect(model.route.endpoint.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat") - const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) expect(prepared.route).toBe("cloudflare-ai-gateway") expect(prepared.body).toMatchObject({ @@ -129,7 +129,7 @@ describe("Cloudflare", () => { openai: { reasoningField: "reasoning", reasoningDetails: merged }, }) - const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged }, ]) @@ -180,7 +180,7 @@ describe("Cloudflare", () => { it.effect("allows a fully configured baseURL override", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: CloudflareAIGateway.configure({ baseURL: "https://gateway.proxy.test/v1/custom/compat", @@ -208,7 +208,7 @@ describe("Cloudflare", () => { }) expect(model.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1") - const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) expect(prepared.route).toBe("cloudflare-workers-ai") expect(prepared.body).toMatchObject({ diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 2b22401e0a..07325b9702 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src" import { Auth, LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import * as Gemini from "../../src/protocols/gemini" import { ProviderShared } from "../../src/protocols/shared" import { it } from "../lib/effect" @@ -26,7 +27,7 @@ const request = LLM.request({ describe("Gemini route", () => { it.effect("prepares Gemini target", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(request) + const prepared = yield* compileRequest(request) expect(prepared.body).toEqual({ contents: [{ role: "user", parts: [{ text: "Say hello." }] }], @@ -38,12 +39,12 @@ describe("Gemini route", () => { it.effect("normalizes Gemini thinking options", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } }, }), ) - const filtered = yield* LLMClient.prepare( + const filtered = yield* compileRequest( LLMRequest.update(request, { providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } }, }), @@ -59,7 +60,7 @@ describe("Gemini route", () => { it.effect("lowers chronological system updates to wrapped user text in order", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")], @@ -75,7 +76,7 @@ describe("Gemini route", () => { it.effect("prepares multimodal user input and tool history", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result", model, @@ -143,7 +144,7 @@ describe("Gemini route", () => { it.effect("continues media tool results as inline model input without base64 text", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -188,7 +189,7 @@ describe("Gemini route", () => { it.effect("strips matching data URLs to raw base64 inlineData", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -229,7 +230,7 @@ describe("Gemini route", () => { ] as const) it.effect(`rejects ${name}`, () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }), ).pipe(Effect.flip) expect(error.message).toMatch(/does not support|does not match|valid base64/) @@ -238,7 +239,7 @@ describe("Gemini route", () => { it.effect("rejects oversized image input", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, messages: [ @@ -256,7 +257,7 @@ describe("Gemini route", () => { it.effect("keeps tools and sends function calling mode NONE", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_choice_none", model, @@ -276,7 +277,7 @@ describe("Gemini route", () => { it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_schema_patch", model, @@ -457,7 +458,7 @@ describe("Gemini route", () => { response.events.findIndex((event) => event.type === "tool-call"), ) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -691,7 +692,7 @@ describe("Gemini route", () => { it.effect("rejects unsupported assistant media content", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_media", model, diff --git a/packages/ai/test/provider/google-vertex.test.ts b/packages/ai/test/provider/google-vertex.test.ts index 395d983ffe..8c031d473c 100644 --- a/packages/ai/test/provider/google-vertex.test.ts +++ b/packages/ai/test/provider/google-vertex.test.ts @@ -4,6 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http" import { LLM } from "../../src" import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers" import { LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import { it } from "../lib/effect" import { dynamicResponse } from "../lib/http" import { deltaChunk, finishChunk } from "../lib/openai-chunks" @@ -182,7 +183,7 @@ describe("Google Vertex providers", () => { it.effect("protects the Vertex Messages API version from body overlays", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model: GoogleVertexMessages.configure({ accessToken: "vertex-token", diff --git a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts index a8b40346cf..4c881d20fb 100644 --- a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts +++ b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts @@ -5,6 +5,7 @@ import { OpenAIChat } from "../../src/protocols/openai-chat" import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenRouter from "../../src/providers/openrouter" import { LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import { recordedTests } from "../recorded-test" import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios" @@ -84,9 +85,7 @@ for (const item of cases) { ), ).toBe(true) - const replay = yield* LLMClient.prepare( - LLM.request({ model: item.model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model: item.model, messages: [response.message] })) expect(replay.body.messages).toMatchObject([ { role: "assistant", content: response.text, reasoning: response.reasoning }, ]) diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 57ae8e8653..27d1f296dd 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -18,6 +18,7 @@ import * as OpenAI from "../../src/providers/openai" import * as OpenAIChat from "../../src/protocols/openai-chat" import { ProviderShared } from "../../src/protocols/shared" import { Auth, LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import { it } from "../lib/effect" import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http" import { deltaChunk, usageChunk } from "../lib/openai-chunks" @@ -42,11 +43,7 @@ const request = LLM.request({ describe("OpenAI Chat route", () => { it.effect("prepares OpenAI Chat payload", () => Effect.gen(function* () { - // Pass the OpenAIChat payload type so `prepared.body` is statically - // typed to the route's native shape — the assertions below read field - // names without `unknown` casts. - const prepared = yield* LLMClient.prepare(request) - const _typed: { readonly model: string; readonly stream: true } = prepared.body + const prepared = yield* compileRequest(request) expect(prepared.body).toEqual({ model: "gpt-4o-mini", @@ -64,7 +61,7 @@ describe("OpenAI Chat route", () => { it.effect("lowers chronological system updates to escaped user wrappers in order", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -87,7 +84,7 @@ describe("OpenAI Chat route", () => { it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -105,7 +102,7 @@ describe("OpenAI Chat route", () => { it.effect("writes reasoning to a configured custom field on every assistant message", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }), messages: [ @@ -131,7 +128,7 @@ describe("OpenAI Chat route", () => { it.effect("rejects reasoning fields that conflict with assistant message fields", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model: Model.update(model, { compatibility: { reasoningField: "content" } }), messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])], @@ -144,7 +141,7 @@ describe("OpenAI Chat route", () => { it.effect("maps OpenAI provider options to Chat options", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"), prompt: "think", @@ -159,7 +156,7 @@ describe("OpenAI Chat route", () => { it.effect("passes through custom OpenAI-compatible reasoning effort strings", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "think", @@ -253,7 +250,7 @@ describe("OpenAI Chat route", () => { it.effect("prepares assistant tool-call and tool-result messages", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result", model, @@ -291,7 +288,7 @@ describe("OpenAI Chat route", () => { it.effect("preserves structured tool errors for the model", () => Effect.gen(function* () { const error = { error: { type: "unknown", message: "Tool execution interrupted" } } - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -311,7 +308,7 @@ describe("OpenAI Chat route", () => { it.effect("continues image tool results as vision input without base64 text", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -355,7 +352,7 @@ describe("OpenAI Chat route", () => { it.effect("orders parallel tool responses before one aggregated vision message", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -405,7 +402,7 @@ describe("OpenAI Chat route", () => { it.effect("aggregates consecutive tool images with a following system update", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -446,7 +443,7 @@ describe("OpenAI Chat route", () => { it.effect("appends system updates without replacing multipart user content", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -474,7 +471,7 @@ describe("OpenAI Chat route", () => { ] as const) it.effect(`rejects ${name}`, () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }), ).pipe(Effect.flip) expect(error.message).toMatch(/does not support|does not match|valid base64/) @@ -483,7 +480,7 @@ describe("OpenAI Chat route", () => { it.effect("rejects oversized image input", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, messages: [ @@ -501,7 +498,7 @@ describe("OpenAI Chat route", () => { it.effect("prepares raw and data URL image media as vision input", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_media", model, @@ -528,7 +525,7 @@ describe("OpenAI Chat route", () => { it.effect("lowers reasoning-only assistant history", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_reasoning", model, @@ -619,9 +616,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: field }, }) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }]) } }), @@ -647,9 +642,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: "vendor_reasoning" }, }) - const replay = yield* LLMClient.prepare( - LLM.request({ model: custom, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model: custom, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }]) }), ) @@ -692,9 +685,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: "reasoning", reasoningDetails: details }, }) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", @@ -737,9 +728,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningDetails: details }, }) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }]) }), ) @@ -764,9 +753,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: "reasoning", reasoningDetails: details }, }) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details }, ]) @@ -839,9 +826,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningDetails: [] }, }) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }]) }), ) @@ -889,9 +874,7 @@ describe("OpenAI Chat route", () => { response.events.findIndex(LLMEvent.is.textStart), ) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged }, ]) @@ -918,9 +901,7 @@ describe("OpenAI Chat route", () => { expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1) expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }]) }), ) @@ -950,7 +931,7 @@ describe("OpenAI Chat route", () => { Effect.gen(function* () { const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 } const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 } - const replay = yield* LLMClient.prepare( + const replay = yield* compileRequest( LLM.request({ model, messages: [ @@ -979,7 +960,7 @@ describe("OpenAI Chat route", () => { it.effect("retains scalar replay for mixed structured reasoning parts", () => Effect.gen(function* () { const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 } - const replay = yield* LLMClient.prepare( + const replay = yield* compileRequest( LLM.request({ model, messages: [ @@ -1004,7 +985,7 @@ describe("OpenAI Chat route", () => { it.effect("replays native scalar reasoning alongside native details", () => Effect.gen(function* () { const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }] - const replay = yield* LLMClient.prepare( + const replay = yield* compileRequest( LLM.request({ model, messages: [ diff --git a/packages/ai/test/provider/openai-compatible-chat.test.ts b/packages/ai/test/provider/openai-compatible-chat.test.ts index 6565820af8..397c64c8b3 100644 --- a/packages/ai/test/provider/openai-compatible-chat.test.ts +++ b/packages/ai/test/provider/openai-compatible-chat.test.ts @@ -3,6 +3,7 @@ import { Effect, Schema } from "effect" import { HttpClientRequest } from "effect/unstable/http" import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src" import { Auth, LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" import { it } from "../lib/effect" @@ -52,7 +53,7 @@ const providerFamilies = [ describe("OpenAI-compatible Chat route", () => { it.effect("prepares generic Chat target", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], toolChoice: ToolChoice.make({ type: "required" }), @@ -127,7 +128,7 @@ describe("OpenAI-compatible Chat route", () => { it.effect("matches AI SDK compatible basic request body fixture", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(request) + const prepared = yield* compileRequest(request) expect(prepared.body).toEqual({ model: "deepseek-chat", @@ -145,7 +146,7 @@ describe("OpenAI-compatible Chat route", () => { it.effect("matches AI SDK compatible tool request body fixture", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_parity", model, diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index f6591f50d2..7d52b10cd1 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -7,6 +7,7 @@ import { OpenResponses } from "../../src/protocols/open-responses" import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses" import { OpenAIResponses } from "../../src/protocols/openai-responses" import { LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import { it } from "../lib/effect" import { fixedResponse } from "../lib/http" import { sseEvents } from "../lib/sse" @@ -23,7 +24,7 @@ describe("Open Responses-compatible route", () => { baseURL: "https://responses.example.test/v1", provider: "example", }).model("example-model") - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, system: "You are concise.", @@ -61,7 +62,7 @@ describe("Open Responses-compatible route", () => { apiKey: "test-key", baseURL: "https://responses.example.test/v1", }).model("example-model") - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }), ).pipe(Effect.flip) @@ -76,7 +77,7 @@ describe("Open Responses-compatible route", () => { apiKey: "test-key", baseURL: "https://responses.example.test/v1", }).model("example-model") - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -102,7 +103,7 @@ describe("Open Responses-compatible route", () => { baseURL: "https://responses.example.test/v1", providerOptions: { openresponses: { reasoningEffort: "low", store: true } }, }).model("example-model") - const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Think." })) + const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." })) expect(prepared.body).toMatchObject({ reasoning: { effort: "low" }, diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index a3d75a7eeb..3b9a0b76aa 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -14,6 +14,7 @@ import { Usage, } from "../../src" import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" +import { compileRequest } from "../../src/route/client" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" import * as XAI from "../../src/providers/xai" @@ -56,7 +57,7 @@ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAITool describe("OpenAI Responses route", () => { it.effect("prepares OpenAI Responses target", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(request) + const prepared = yield* compileRequest(request) expect(prepared.body).toEqual({ model: "gpt-4.1-mini", @@ -74,7 +75,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers the hosted OpenAI image generation tool", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "Show me a rooftop garden.", @@ -92,7 +93,7 @@ describe("OpenAI Responses route", () => { it.effect("rejects invalid hosted image generation options locally", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, prompt: "Show me a rooftop garden.", @@ -109,7 +110,7 @@ describe("OpenAI Responses route", () => { Effect.gen(function* () { const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } }) expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } }) - const prepared = yield* LLMClient.prepare(input) + const prepared = yield* compileRequest(input) expect(prepared.body).toMatchObject({ service_tier: "priority" }) expect(prepared.body).not.toHaveProperty("serviceTier") @@ -118,7 +119,7 @@ describe("OpenAI Responses route", () => { it.effect("passes through custom OpenAI reasoning effort strings", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }), ) @@ -128,7 +129,7 @@ describe("OpenAI Responses route", () => { it.effect("omits unsupported semantic service tiers", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }), ) @@ -138,7 +139,7 @@ describe("OpenAI Responses route", () => { it.effect("flattens top-level object unions in function schemas", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { tools: [ ToolDefinition.make({ @@ -191,7 +192,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers chronological system updates to escaped user wrappers in order", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -217,7 +218,7 @@ describe("OpenAI Responses route", () => { it.effect("prepares OpenAI Responses WebSocket target", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { model: OpenAIResponses.webSocketRoute .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) @@ -395,7 +396,7 @@ describe("OpenAI Responses route", () => { it.effect("prepares function call and function output input items", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result", model, @@ -432,7 +433,7 @@ describe("OpenAI Responses route", () => { content: [], structured: {}, } - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -453,7 +454,7 @@ describe("OpenAI Responses route", () => { it.effect("keeps primitive tool errors as plain text", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -469,7 +470,7 @@ describe("OpenAI Responses route", () => { it.effect("keeps non-JSON tool errors as plain text", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -487,7 +488,7 @@ describe("OpenAI Responses route", () => { // image data is not JSON-stringified into `function_call_output.output`. it.effect("lowers image tool-result content as structured input_image items", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result_image", model, @@ -516,7 +517,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers single-image tool-result content as structured input_image array", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result_image_only", model, @@ -540,7 +541,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers PDF tool-result content as structured input_file array", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result_pdf", model, @@ -575,7 +576,7 @@ describe("OpenAI Responses route", () => { it.effect("uses xAI inline file encoding for PDF tool results", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: xaiModel, messages: [ @@ -610,7 +611,7 @@ describe("OpenAI Responses route", () => { it.effect("rejects unsupported media in tool-result content with a clear error", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_tool_result_unsupported_media", model, @@ -633,7 +634,7 @@ describe("OpenAI Responses route", () => { it.effect("prepares the composed native continuation request", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( continuationRequest({ id: "req_native_continuation_openai", model, @@ -675,7 +676,7 @@ describe("OpenAI Responses route", () => { it.effect("maps OpenAI provider options to Responses options", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"), prompt: "think", @@ -700,7 +701,7 @@ describe("OpenAI Responses route", () => { it.effect("accepts the full ResponseIncludable union", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "hi", @@ -722,7 +723,7 @@ describe("OpenAI Responses route", () => { it.effect("filters unknown includable values out of the include array", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "hi", @@ -739,7 +740,7 @@ describe("OpenAI Responses route", () => { it.effect("treats an explicit empty include as no include at all", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }), ) @@ -749,7 +750,7 @@ describe("OpenAI Responses route", () => { it.effect("treats an all-invalid include as no include at all", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }), ) @@ -759,7 +760,7 @@ describe("OpenAI Responses route", () => { it.effect("omits include when no include is set", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }), ) @@ -773,7 +774,7 @@ describe("OpenAI Responses route", () => { // reasoningSummary: "auto" by default. Without `include`, a follow-up // turn cannot replay reasoning state, so the facade also opts into // `reasoning.encrypted_content` automatically. - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), prompt: "hi", @@ -788,7 +789,7 @@ describe("OpenAI Responses route", () => { it.effect("lets callers opt out of the GPT-5 default include", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), prompt: "hi", @@ -802,7 +803,7 @@ describe("OpenAI Responses route", () => { it.effect("request OpenAI provider options override route defaults", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", @@ -934,9 +935,7 @@ describe("OpenAI Responses route", () => { }, ]) - const prepared = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(prepared.body.input).toEqual([ { role: "assistant", @@ -1270,7 +1269,7 @@ describe("OpenAI Responses route", () => { it.effect("preserves assistant content order around reasoning items", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_reasoning_order", model, @@ -1308,7 +1307,7 @@ describe("OpenAI Responses route", () => { it.effect("references stored reasoning items by id", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -1330,7 +1329,7 @@ describe("OpenAI Responses route", () => { it.effect("references stored provider-executed hosted tool results by id", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -1367,7 +1366,7 @@ describe("OpenAI Responses route", () => { it.effect("continues stateless hosted image generation with the generated image", () => Effect.gen(function* () { const imageTool = OpenAI.imageGeneration({ action: "edit" }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -1408,7 +1407,7 @@ describe("OpenAI Responses route", () => { it.effect("joins streamed summary blocks into one continuation reasoning item", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_multi_summary_continuation", model, @@ -1445,7 +1444,7 @@ describe("OpenAI Responses route", () => { it.effect("skips non-persisted reasoning ids without encrypted state", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_reasoning_without_encrypted_state", model, @@ -1762,7 +1761,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers user image and PDF content", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_media", model, @@ -1793,7 +1792,7 @@ describe("OpenAI Responses route", () => { it.effect("uses xAI inline file encoding for user PDFs", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: xaiModel, messages: [ @@ -1825,7 +1824,7 @@ describe("OpenAI Responses route", () => { it.effect("rejects unsupported user media content", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_media", model, diff --git a/packages/ai/test/provider/openrouter.test.ts b/packages/ai/test/provider/openrouter.test.ts index b4ac2fe2e5..e2a61c250e 100644 --- a/packages/ai/test/provider/openrouter.test.ts +++ b/packages/ai/test/provider/openrouter.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { LLM, Message } from "../../src" import { LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import * as OpenRouter from "../../src/providers/openrouter" import { it } from "../lib/effect" import { fixedResponse } from "../lib/http" @@ -19,7 +20,7 @@ describe("OpenRouter", () => { }) expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1") - const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) expect(prepared.route).toBe("openrouter") expect(prepared.body).toMatchObject({ @@ -32,7 +33,7 @@ describe("OpenRouter", () => { it.effect("applies OpenRouter payload options from the model helper", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenRouter.configure({ apiKey: "test-key", @@ -100,7 +101,7 @@ describe("OpenRouter", () => { { type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 }, { type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 }, ] - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), messages: [ @@ -133,7 +134,7 @@ describe("OpenRouter", () => { { type: "reasoning.encrypted", id: "state", data: "opaque" }, { type: "reasoning.encrypted", id: "state", data: "opaque" }, ] - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), messages: [ @@ -158,7 +159,7 @@ describe("OpenRouter", () => { { type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" }, { type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" }, ] - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), messages: [ @@ -179,7 +180,7 @@ describe("OpenRouter", () => { it.effect("omits scalar reasoning without continuation details", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), messages: [Message.assistant({ type: "reasoning", text: "Thinking" })], diff --git a/packages/ai/test/tool-schema-projection.test.ts b/packages/ai/test/tool-schema-projection.test.ts index a9df815daf..4f83942a66 100644 --- a/packages/ai/test/tool-schema-projection.test.ts +++ b/packages/ai/test/tool-schema-projection.test.ts @@ -3,7 +3,8 @@ import { Effect } from "effect" import { LLM } from "../src" import { OpenAIChat } from "../src/protocols" import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema" -import { Auth, LLMClient } from "../src/route" +import { Auth } from "../src/route" +import { compileRequest } from "../src/route/client" import { it } from "./lib/effect" describe("tool schema projections", () => { @@ -79,7 +80,7 @@ describe("tool schema projections", () => { const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "Use the tool.", diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 87a02d1b3c..e3dfbe8d80 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -1,9 +1,10 @@ -import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider" +import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider" import { AISDK } from "@opencode-ai/core/aisdk" import { Model } from "@opencode-ai/core/model" import { Provider } from "@opencode-ai/core/provider" import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai" import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route" +import { compileRequest } from "@opencode-ai/ai/route/client" import { expect } from "bun:test" import { Effect, Layer } from "effect" import { testEffect } from "./lib/effect" @@ -87,9 +88,7 @@ it.effect("projects request settings, headers, and body overlays", () => headers: { "x-test": "header" }, body: { safety_setting: "strict" }, }) - const prepared = yield* LLMClient.prepare( - LLM.request({ model: resolved, prompt: "Hello" }), - ) + const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) expect(prepared.body.providerOptions).toEqual({ google: { thinkingConfig: { thinkingBudget: 1024 } }, @@ -112,9 +111,7 @@ it.effect("maps pro reasoning bodies to AI SDK provider options", () => ...model("@ai-sdk/openai"), body: { reasoning: { mode: "pro" } }, }) - const prepared = yield* LLMClient.prepare( - LLM.request({ model: resolved, prompt: "Hello" }), - ) + const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) expect(body).toBeUndefined() expect(prepared.body.providerOptions).toEqual({ @@ -139,9 +136,7 @@ it.effect("maps package-specific AI SDK provider option keys", () => ] as const for (const [packageName, key, settings] of cases) { const resolved = yield* aisdk.model(model(packageName, { reasoningEffort: "high" })) - const prepared = yield* LLMClient.prepare( - LLM.request({ model: resolved, prompt: "Hello" }), - ) + const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) expect(prepared.body.providerOptions).toEqual({ [key]: settings }) } }), @@ -155,17 +150,13 @@ it.effect("forces reasoning and projects both Azure AI SDK namespaces", () => }) const openai = yield* aisdk.model(model("@ai-sdk/openai", { reasoningEffort: "high" })) - const openaiPrepared = yield* LLMClient.prepare( - LLM.request({ model: openai, prompt: "Hello" }), - ) + const openaiPrepared = yield* compileRequest(LLM.request({ model: openai, prompt: "Hello" })) expect(openaiPrepared.body.providerOptions).toEqual({ openai: { reasoningEffort: "high", forceReasoning: true }, }) const azure = yield* aisdk.model(model("@ai-sdk/azure", { reasoningEffort: "high" })) - const azurePrepared = yield* LLMClient.prepare( - LLM.request({ model: azure, prompt: "Hello" }), - ) + const azurePrepared = yield* compileRequest(LLM.request({ model: azure, prompt: "Hello" })) expect(azurePrepared.body.providerOptions).toEqual({ openai: { reasoningEffort: "high", forceReasoning: true }, azure: { reasoningEffort: "high", forceReasoning: true }, @@ -187,9 +178,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () => }), modelID: Model.ID.make("anthropic/claude-sonnet-5"), }) - const anthropicPrepared = yield* LLMClient.prepare( - LLM.request({ model: anthropic, prompt: "Hello" }), - ) + const anthropicPrepared = yield* compileRequest(LLM.request({ model: anthropic, prompt: "Hello" })) expect(anthropicPrepared.body.providerOptions).toEqual({ gateway: { order: ["anthropic"] }, anthropic: { thinking: { type: "adaptive" } }, @@ -199,9 +188,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () => ...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }), modelID: Model.ID.make("amazon/nova-2-lite"), }) - const bedrockPrepared = yield* LLMClient.prepare( - LLM.request({ model: bedrock, prompt: "Hello" }), - ) + const bedrockPrepared = yield* compileRequest(LLM.request({ model: bedrock, prompt: "Hello" })) expect(bedrockPrepared.body.providerOptions).toEqual({ bedrock: { reasoningConfig: { type: "enabled" } }, }) @@ -210,9 +197,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () => ...model("@ai-sdk/gateway", { reasoningEffort: "high" }), modelID: Model.ID.make("deepseek/deepseek-v4"), }) - const fallbackPrepared = yield* LLMClient.prepare( - LLM.request({ model: fallback, prompt: "Hello" }), - ) + const fallbackPrepared = yield* compileRequest(LLM.request({ model: fallback, prompt: "Hello" })) expect(fallbackPrepared.body.providerOptions).toEqual({ deepseek: { reasoningEffort: "high" }, }) @@ -228,7 +213,7 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () => const resolved = yield* aisdk.model(model("@ai-sdk/anthropic")) expect(resolved.route.providerMetadataKey).toBe("anthropic") - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: resolved, messages: [ diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 547893a088..273c15a8c6 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { LLM, Model } from "@opencode-ai/ai" -import { LLMClient } from "@opencode-ai/ai/route" +import { compileRequest } from "@opencode-ai/ai/route/client" import { Effect } from "effect" import { Headers } from "effect/unstable/http" import { Credential } from "@opencode-ai/core/credential" @@ -69,7 +69,7 @@ describe("ModelResolver", () => { settings: { apiKey: "secret", baseURL: "https://openai.example/v1" }, }), ) - const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) + const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) expect(JSON.stringify(prepared.body)).not.toContain("apiKey") expect(JSON.stringify(prepared.body)).not.toContain("secret") diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index b8f46d91af..a64f2367d5 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -41,7 +41,6 @@ const projects = Layer.succeed( ) let requests: LLMRequest[] = [] const client = Layer.mock(LLMClient.Service)({ - prepare: () => Effect.die("unused"), stream: (request: LLMRequest) => { requests.push(request) return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual session summary" })) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 89f1f18d39..aa781f40da 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -42,7 +42,6 @@ const cost = [ }, ] const client = Layer.mock(LLMClient.Service)({ - prepare: () => Effect.die("unused"), stream: (request: LLMRequest) => { requests.push(request) return Stream.make( diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index ea12b528f2..7ca2a9b18f 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -49,7 +49,6 @@ const sessionID = SessionSchema.ID.make("ses_generate_test") const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route }) const client = Layer.mock(LLMClient.Service)({ - prepare: () => Effect.die(new Error("unused")), stream: () => Stream.die(new Error("unused")), generate: (request) => Effect.sync(() => { diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index b69abb77bc..6c95cdb009 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -40,7 +40,6 @@ const cost = [ }, ] const client = Layer.mock(LLMClient.Service)({ - prepare: () => Effect.die("unused"), stream: (request: LLMRequest) => { requests.push(request) return Stream.make( From 309c4fe6f0eb56b3fe63257bb95f2b1b0ee40f81 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:05:56 +0530 Subject: [PATCH 02/51] feat(ai): infer model provider options (#39493) --- packages/ai/src/llm.ts | 27 ++++++---- packages/ai/src/provider-package.ts | 9 ++-- packages/ai/src/route/client.ts | 28 +++++++--- packages/ai/src/schema/options.ts | 32 ++++++----- packages/ai/test/llm-option-types.types.ts | 62 ++++++++++++++++++++++ 5 files changed, 125 insertions(+), 33 deletions(-) create mode 100644 packages/ai/test/llm-option-types.types.ts diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts index 8b6f904a5b..77ec766e9a 100644 --- a/packages/ai/src/llm.ts +++ b/packages/ai/src/llm.ts @@ -9,25 +9,28 @@ import { LLMRequest, LLMResponse, Message, + Model, SystemPart, ToolChoice, ToolDefinition, type ContentPart, + type ModelProviderOptions, } from "./schema" import { make as makeTool, toDefinitions, type ToolSchema } from "./tool" /** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */ -export type RequestInput = Omit< +export type RequestInput = Omit< ConstructorParameters[0], - "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions" + "model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions" > & { + readonly model: SelectedModel readonly system?: string | SystemPart | ReadonlyArray readonly prompt?: string | ContentPart | ReadonlyArray readonly messages?: ReadonlyArray readonly tools?: ReadonlyArray readonly toolChoice?: ToolChoice.Input readonly generation?: GenerationOptions.Input - readonly providerOptions?: ConstructorParameters[0]["providerOptions"] + readonly providerOptions?: NoInfer> readonly http?: HttpOptions.Input } @@ -35,7 +38,7 @@ export const generate = LLMClient.generate export const stream = LLMClient.stream -export const request = (input: RequestInput) => { +export const request = (input: RequestInput) => { const { system: requestSystem, prompt, @@ -63,7 +66,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object" const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool." -type GenerateObjectBase = Omit +type GenerateObjectBase = Omit, "tools" | "toolChoice"> export class GenerateObjectResponse { constructor( @@ -80,11 +83,13 @@ export class GenerateObjectResponse { } } -export interface GenerateObjectOptions> extends GenerateObjectBase { +export interface GenerateObjectOptions, SelectedModel extends Model = Model> + extends GenerateObjectBase { readonly schema: S } -export interface GenerateObjectDynamicOptions extends GenerateObjectBase { +export interface GenerateObjectDynamicOptions + extends GenerateObjectBase { /** Raw JSON Schema object describing the expected output shape. */ readonly jsonSchema: JsonSchema.JsonSchema } @@ -137,11 +142,11 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* ( * 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when * the schema is only available at runtime (MCP, plugin manifests). Caller validates. */ -export function generateObject>( - options: GenerateObjectOptions, +export function generateObject>( + options: GenerateObjectOptions, ): Effect.Effect>, LLMError> -export function generateObject( - options: GenerateObjectDynamicOptions, +export function generateObject( + options: GenerateObjectDynamicOptions, ): Effect.Effect, LLMError> export function generateObject(options: GenerateObjectOptions> | GenerateObjectDynamicOptions) { if ("schema" in options) { diff --git a/packages/ai/src/provider-package.ts b/packages/ai/src/provider-package.ts index fd878a9d06..4f7dc0dcad 100644 --- a/packages/ai/src/provider-package.ts +++ b/packages/ai/src/provider-package.ts @@ -1,4 +1,4 @@ -import type { Model } from "./schema" +import type { Model, ProviderOptions } from "./schema" export interface Settings extends Readonly> { readonly headers?: Readonly> @@ -9,8 +9,11 @@ export interface Settings extends Readonly> { } } -export interface Definition { - readonly model: (modelID: string, settings: ProviderSettings) => Model +export interface Definition< + ProviderSettings extends Settings = Settings, + Options extends ProviderOptions = ProviderOptions, +> { + readonly model: (modelID: string, settings: ProviderSettings) => Model } export * as ProviderPackage from "./provider-package" diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 048f82f554..98d0cab83c 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -45,7 +45,9 @@ export interface Route { readonly defaults: RouteDefaults readonly body: RouteBody readonly with: (patch: RoutePatch) => Route - readonly model: (input: RouteMappedModelInput) => Model + readonly model: ( + input: RouteMappedModelInput, + ) => Model readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect readonly streamPrepared: ( prepared: Prepared, @@ -62,9 +64,15 @@ export type AnyRoute = Route export type HttpOptionsInput = HttpOptions.Input -export type RouteModelInput = Omit +export type RouteModelInput = Omit< + Model.Input, + "provider" | "route" +> -export type RouteRoutedModelInput = Omit +export type RouteRoutedModelInput = Omit< + Model.Input, + "route" +> export interface RouteDefaults { readonly headers?: Record @@ -90,14 +98,19 @@ export interface RoutePatch extends RouteDefaultsInput { readonly endpoint?: EndpointPatch } -type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput +type RouteMappedModelInput = + | RouteModelInput + | RouteRoutedModelInput -const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => { +const makeRouteModel = ( + route: AnyRoute, + mapped: RouteMappedModelInput, +) => { const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined) if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) if (!endpointBaseURL(route.endpoint)) throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`) - return Model.make({ + return Model.make({ ...mapped, provider, route, @@ -284,7 +297,8 @@ function makeFromTransport( defaults: mergeRouteDefaults(route.defaults, defaults), }) }, - model: (input) => makeRouteModel(route, input), + model: (input: RouteMappedModelInput) => + makeRouteModel(route, input), prepareTransport: (body, request) => routeInput.transport.prepare({ body, diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index c80bce0fda..668954d1cf 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -139,15 +139,17 @@ export class ModelDefaults extends Schema.Class("LLM.ModelDefault generation: Schema.optional(GenerationOptions), providerOptions: Schema.optional(ProviderOptions), http: Schema.optional(HttpOptions), -}) {} +}) { + declare protected readonly _ModelDefaults: void +} export namespace ModelDefaults { - export type Input = + export type Input = | ModelDefaults | { readonly limits?: ModelLimits.Input readonly generation?: GenerationOptions.Input - readonly providerOptions?: ProviderOptions + readonly providerOptions?: Options readonly http?: HttpOptions.Input } @@ -178,7 +180,8 @@ export namespace ModelCompatibility { export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input)) } -export class Model { +export class Model { + declare protected readonly _ProviderOptions: Options readonly id: ModelID readonly provider: ProviderID readonly route: AnyRoute @@ -193,8 +196,8 @@ export class Model { this.compatibility = input.compatibility } - static make(input: Model.Input) { - return new Model({ + static make(input: Model.Input) { + return new Model({ id: ModelID.make(input.id), provider: ProviderID.make(input.provider), route: input.route, @@ -203,7 +206,7 @@ export class Model { }) } - static input(model: Model): Model.ConstructorInput { + static input(model: Model): Model.ConstructorInput { return { id: model.id, provider: model.provider, @@ -213,9 +216,9 @@ export class Model { } } - static update(model: Model, patch: Partial) { + static update(model: Model, patch: Partial>) { if (Object.keys(patch).length === 0) return model - return Model.make({ + return Model.make({ ...Model.input(model), ...patch, }) @@ -231,15 +234,20 @@ export namespace Model { readonly compatibility?: ModelCompatibility } - export type Input = Omit & { + export type Input = Omit< + ConstructorInput, + "id" | "provider" | "defaults" | "compatibility" + > & { readonly id: string | ModelID readonly provider: string | ProviderID - readonly defaults?: ModelDefaults.Input + readonly defaults?: ModelDefaults.Input readonly compatibility?: ModelCompatibility.Input } } -export type ModelInput = Model.Input +export type ModelInput = Model.Input + +export type ModelProviderOptions = SelectedModel extends Model ? Options : never export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" }) diff --git a/packages/ai/test/llm-option-types.types.ts b/packages/ai/test/llm-option-types.types.ts new file mode 100644 index 0000000000..d13ebdcc43 --- /dev/null +++ b/packages/ai/test/llm-option-types.types.ts @@ -0,0 +1,62 @@ +import { Schema } from "effect" +import { LLM, Model, type ModelProviderOptions, type ProviderOptions } from "../src" +import { OpenAIChat } from "../src/protocols" + +interface ExampleOptions { + readonly [key: string]: unknown + readonly mode?: "fast" | "thorough" +} + +type ExampleProviderOptions = ProviderOptions & { + readonly example?: ExampleOptions +} + +const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://example.com/v1" } }) + .model({ id: "example" }) + +LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } }) +LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error Known provider options preserve their value types. + providerOptions: { example: { mode: "slow" } }, +}) + +LLM.generateObject({ + model, + prompt: "Hello", + schema: Schema.Struct({ answer: Schema.String }), + providerOptions: { example: { mode: "thorough" } }, +}) + +LLM.generateObject({ + model, + prompt: "Hello", + jsonSchema: { type: "object" }, + // @ts-expect-error Dynamic object generation uses the selected model's provider options. + providerOptions: { example: { mode: false } }, +}) + +declare const generic: Model +LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } }) + +const options: ModelProviderOptions = { example: { mode: "fast" } } +void options + +model.route.model({ + id: "example-with-defaults", + defaults: { + // @ts-expect-error Low-level model defaults preserve known provider option types. + providerOptions: { example: { mode: 1 } }, + }, +}) + +Model.update(model, { + defaults: { + // @ts-expect-error Updating a model cannot contradict its provider option type. + providerOptions: { example: { mode: "slow" } }, + }, +}) From 5882b64612388b11b3b93ad7e47938b6c7601224 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:07:24 +0530 Subject: [PATCH 03/51] feat(ai): type Anthropic-compatible request options (#39497) --- packages/ai/src/providers/anthropic-compatible.ts | 7 +++++-- .../provider-options/anthropic-compatible.types.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 packages/ai/test/provider-options/anthropic-compatible.types.ts diff --git a/packages/ai/src/providers/anthropic-compatible.ts b/packages/ai/src/providers/anthropic-compatible.ts index 56cc7cee32..93d1bac048 100644 --- a/packages/ai/src/providers/anthropic-compatible.ts +++ b/packages/ai/src/providers/anthropic-compatible.ts @@ -47,7 +47,7 @@ export const configure = (input: Config) => { }) return { id: ProviderID.make(provider), - model: (modelID: string | ModelID) => route.model({ id: modelID }), + model: (modelID: string | ModelID) => route.model({ id: modelID }), configure, } } @@ -57,7 +57,10 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { +export const model: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => { if (settings.apiKey !== undefined && settings.authToken !== undefined) throw new Error("Anthropic-compatible apiKey cannot be combined with authToken") return configure({ diff --git a/packages/ai/test/provider-options/anthropic-compatible.types.ts b/packages/ai/test/provider-options/anthropic-compatible.types.ts new file mode 100644 index 0000000000..3f7de8fd14 --- /dev/null +++ b/packages/ai/test/provider-options/anthropic-compatible.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { AnthropicCompatible } from "../../src/providers" + +const model = AnthropicCompatible.configure({ baseURL: "https://example.com" }).model("claude") + +LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { effort: "high" } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error Anthropic effort must be a string. + providerOptions: { anthropic: { effort: 1 } }, +}) From 8c3e06798c5b145d29edd1b52c3dc9dc572bde8d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:18:00 +0530 Subject: [PATCH 04/51] refactor(ai): limit provider option inference (#39510) --- packages/ai/src/route/client.ts | 25 ++++++---------------- packages/ai/src/schema/options.ts | 21 +++++++----------- packages/ai/test/llm-option-types.types.ts | 17 +-------------- 3 files changed, 15 insertions(+), 48 deletions(-) diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 98d0cab83c..b935699f90 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -45,9 +45,7 @@ export interface Route { readonly defaults: RouteDefaults readonly body: RouteBody readonly with: (patch: RoutePatch) => Route - readonly model: ( - input: RouteMappedModelInput, - ) => Model + readonly model: (input: RouteMappedModelInput) => Model readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect readonly streamPrepared: ( prepared: Prepared, @@ -64,15 +62,9 @@ export type AnyRoute = Route export type HttpOptionsInput = HttpOptions.Input -export type RouteModelInput = Omit< - Model.Input, - "provider" | "route" -> +export type RouteModelInput = Omit -export type RouteRoutedModelInput = Omit< - Model.Input, - "route" -> +export type RouteRoutedModelInput = Omit export interface RouteDefaults { readonly headers?: Record @@ -98,14 +90,9 @@ export interface RoutePatch extends RouteDefaultsInput { readonly endpoint?: EndpointPatch } -type RouteMappedModelInput = - | RouteModelInput - | RouteRoutedModelInput +type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput -const makeRouteModel = ( - route: AnyRoute, - mapped: RouteMappedModelInput, -) => { +const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => { const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined) if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) if (!endpointBaseURL(route.endpoint)) @@ -297,7 +284,7 @@ function makeFromTransport( defaults: mergeRouteDefaults(route.defaults, defaults), }) }, - model: (input: RouteMappedModelInput) => + model: (input: RouteMappedModelInput) => makeRouteModel(route, input), prepareTransport: (body, request) => routeInput.transport.prepare({ diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index 668954d1cf..df7c6544aa 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -139,17 +139,15 @@ export class ModelDefaults extends Schema.Class("LLM.ModelDefault generation: Schema.optional(GenerationOptions), providerOptions: Schema.optional(ProviderOptions), http: Schema.optional(HttpOptions), -}) { - declare protected readonly _ModelDefaults: void -} +}) {} export namespace ModelDefaults { - export type Input = + export type Input = | ModelDefaults | { readonly limits?: ModelLimits.Input readonly generation?: GenerationOptions.Input - readonly providerOptions?: Options + readonly providerOptions?: ProviderOptions readonly http?: HttpOptions.Input } @@ -196,7 +194,7 @@ export class Model { this.compatibility = input.compatibility } - static make(input: Model.Input) { + static make(input: Model.Input) { return new Model({ id: ModelID.make(input.id), provider: ProviderID.make(input.provider), @@ -216,7 +214,7 @@ export class Model { } } - static update(model: Model, patch: Partial>) { + static update(model: Model, patch: Partial) { if (Object.keys(patch).length === 0) return model return Model.make({ ...Model.input(model), @@ -234,18 +232,15 @@ export namespace Model { readonly compatibility?: ModelCompatibility } - export type Input = Omit< - ConstructorInput, - "id" | "provider" | "defaults" | "compatibility" - > & { + export type Input = Omit & { readonly id: string | ModelID readonly provider: string | ProviderID - readonly defaults?: ModelDefaults.Input + readonly defaults?: ModelDefaults.Input readonly compatibility?: ModelCompatibility.Input } } -export type ModelInput = Model.Input +export type ModelInput = Model.Input export type ModelProviderOptions = SelectedModel extends Model ? Options : never diff --git a/packages/ai/test/llm-option-types.types.ts b/packages/ai/test/llm-option-types.types.ts index d13ebdcc43..8171a7b2e9 100644 --- a/packages/ai/test/llm-option-types.types.ts +++ b/packages/ai/test/llm-option-types.types.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { LLM, Model, type ModelProviderOptions, type ProviderOptions } from "../src" +import { LLM, type Model, type ModelProviderOptions, type ProviderOptions } from "../src" import { OpenAIChat } from "../src/protocols" interface ExampleOptions { @@ -45,18 +45,3 @@ LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { o const options: ModelProviderOptions = { example: { mode: "fast" } } void options - -model.route.model({ - id: "example-with-defaults", - defaults: { - // @ts-expect-error Low-level model defaults preserve known provider option types. - providerOptions: { example: { mode: 1 } }, - }, -}) - -Model.update(model, { - defaults: { - // @ts-expect-error Updating a model cannot contradict its provider option type. - providerOptions: { example: { mode: "slow" } }, - }, -}) From 9d6af6afa4d3c2721214d8897a203c9ff5bb40f0 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:18:24 +0530 Subject: [PATCH 05/51] feat(ai): type compatible request options (#39509) --- packages/ai/src/providers/openai-compatible.ts | 12 ++++++++---- .../provider-options/openai-compatible.types.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 packages/ai/test/provider-options/openai-compatible.types.ts diff --git a/packages/ai/src/providers/openai-compatible.ts b/packages/ai/src/providers/openai-compatible.ts index 8b5d23eed7..e4e1be32fc 100644 --- a/packages/ai/src/providers/openai-compatible.ts +++ b/packages/ai/src/providers/openai-compatible.ts @@ -4,13 +4,15 @@ import type { RouteDefaultsInput } from "../route/client" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import type { ProviderPackage } from "../provider-package" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" +import type { OpenAIProviderOptionsInput } from "./openai-options" export const id = ProviderID.make("openai-compatible") -type GenericModelOptions = RouteDefaultsInput & +type GenericModelOptions = Omit & ProviderAuthOption<"optional"> & { readonly provider?: string readonly baseURL: string + readonly providerOptions?: OpenAIProviderOptionsInput } export interface Settings extends ProviderPackage.Settings { @@ -19,9 +21,10 @@ export interface Settings extends ProviderPackage.Settings { readonly provider?: string } -export type FamilyModelOptions = RouteDefaultsInput & +export type FamilyModelOptions = Omit & ProviderAuthOption<"optional"> & { readonly baseURL?: string + readonly providerOptions?: OpenAIProviderOptionsInput } export const routes = [OpenAICompatibleChat.route] @@ -37,7 +40,8 @@ export const configure = (input: GenericModelOptions) => { }) return { id: ProviderID.make(provider), - model: (modelID: string | ModelID) => route.model({ id: modelID, provider: ProviderID.make(provider) }), + model: (modelID: string | ModelID) => + route.model({ id: modelID, provider: ProviderID.make(provider) }), configure, } } @@ -63,7 +67,7 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => configure({ apiKey: settings.apiKey, baseURL: settings.baseURL, diff --git a/packages/ai/test/provider-options/openai-compatible.types.ts b/packages/ai/test/provider-options/openai-compatible.types.ts new file mode 100644 index 0000000000..94cfd2d24c --- /dev/null +++ b/packages/ai/test/provider-options/openai-compatible.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { OpenAICompatible } from "../../src/providers" + +const model = OpenAICompatible.deepseek.model("deepseek-chat") + +LLM.request({ model, prompt: "Hello", providerOptions: { openai: { store: false } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error OpenAI-compatible store must be boolean. + providerOptions: { openai: { store: "false" } }, +}) From 5a78a17e49f3b38989ed374bb388edb731f192ea Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:18:43 +0530 Subject: [PATCH 06/51] feat(ai): type OpenRouter request options (#39508) --- packages/ai/src/providers/openrouter.ts | 2 +- .../ai/test/provider-options/openrouter.types.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 packages/ai/test/provider-options/openrouter.types.ts diff --git a/packages/ai/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts index 10b7fdd1e9..4bcdb3f9db 100644 --- a/packages/ai/src/providers/openrouter.ts +++ b/packages/ai/src/providers/openrouter.ts @@ -107,7 +107,7 @@ export const configure = (input: ModelOptions = {}) => { const route = configuredRoute(input) return { id, - model: (modelID: string | ModelID) => route.model({ id: modelID }), + model: (modelID: string | ModelID) => route.model({ id: modelID }), configure, } } diff --git a/packages/ai/test/provider-options/openrouter.types.ts b/packages/ai/test/provider-options/openrouter.types.ts new file mode 100644 index 0000000000..8982a50edb --- /dev/null +++ b/packages/ai/test/provider-options/openrouter.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { OpenRouter } from "../../src/providers" + +const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5") + +LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error OpenRouter usage must be boolean or an option record. + providerOptions: { openrouter: { usage: "yes" } }, +}) From 333a090975cd76ba102359fccb45d2c2f89bdb97 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:19:02 +0530 Subject: [PATCH 07/51] feat(ai): type Vertex Responses request options (#39500) --- .../ai/src/providers/google-vertex-responses.ts | 13 +++++++++---- .../google-vertex-responses.types.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 packages/ai/test/provider-options/google-vertex-responses.types.ts diff --git a/packages/ai/src/providers/google-vertex-responses.ts b/packages/ai/src/providers/google-vertex-responses.ts index e36c67472c..d03358f36c 100644 --- a/packages/ai/src/providers/google-vertex-responses.ts +++ b/packages/ai/src/providers/google-vertex-responses.ts @@ -1,8 +1,9 @@ import type { ProviderPackage } from "../provider-package" import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses" import type { RouteDefaultsInput } from "../route/client" -import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import { ProviderID, type ModelID } from "../schema" import { GoogleVertexShared } from "./google-vertex-shared" +import type { OpenResponsesProviderOptionsInput } from "./open-responses-options" export const id = ProviderID.make("google-vertex") @@ -11,6 +12,7 @@ export type Config = RouteDefaultsInput & readonly baseURL?: string readonly location?: string readonly project?: string + readonly providerOptions?: OpenResponsesProviderOptionsInput } export interface Settings extends ProviderPackage.Settings { @@ -19,7 +21,7 @@ export interface Settings extends ProviderPackage.Settings { readonly baseURL?: string readonly location?: string readonly project?: string - readonly providerOptions?: ProviderOptions + readonly providerOptions?: OpenResponsesProviderOptionsInput } const route = OpenAICompatibleResponses.route.with({ @@ -58,7 +60,7 @@ export const configure = (input: Config = {}) => { const route = configuredRoute(input) return { id, - model: (modelID: string | ModelID) => route.model({ id: modelID }), + model: (modelID: string | ModelID) => route.model({ id: modelID }), configure, } } @@ -68,7 +70,10 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { +export const model: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => { if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys") return configure({ accessToken: settings.accessToken, diff --git a/packages/ai/test/provider-options/google-vertex-responses.types.ts b/packages/ai/test/provider-options/google-vertex-responses.types.ts new file mode 100644 index 0000000000..3a64689102 --- /dev/null +++ b/packages/ai/test/provider-options/google-vertex-responses.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { GoogleVertexResponses } from "../../src/providers" + +const model = GoogleVertexResponses.configure({ accessToken: "test", project: "project" }).model("gemini") + +LLM.request({ model, prompt: "Hello", providerOptions: { openresponses: { textVerbosity: "high" } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error Vertex Responses verbosity uses the Open Responses union. + providerOptions: { openresponses: { textVerbosity: "verbose" } }, +}) From 8a96b80aec11a7e758fb5f9396bc221416b31b60 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:19:26 +0530 Subject: [PATCH 08/51] feat(ai): type OpenAI request options (#39495) --- packages/ai/src/providers/openai.ts | 19 +++++++++++++------ .../ai/test/provider-options/openai.types.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 packages/ai/test/provider-options/openai.types.ts diff --git a/packages/ai/src/providers/openai.ts b/packages/ai/src/providers/openai.ts index 862333e2e5..c8213644b8 100644 --- a/packages/ai/src/providers/openai.ts +++ b/packages/ai/src/providers/openai.ts @@ -86,10 +86,15 @@ export const configure = (input: Config = {}) => { const chatRoute = configuredRoute(OpenAIChat.route, input) const modelDefaults = defaults(input) const responses = (id: string | ModelID) => - responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id }) + responsesRoute + .with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })) + .model({ id }) const responsesWebSocket = (id: string | ModelID) => - responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id }) - const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id }) + responsesWebSocketRoute + .with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })) + .model({ id }) + const chat = (id: string | ModelID) => + chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id }) const image = (modelID: string | ModelID) => OpenAIImages.model({ id: modelID, @@ -132,15 +137,17 @@ const config = (settings: Settings): Config => { } } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { const configured = configure(config(settings)) if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID) if (settings.transport === "websocket") return configured.responsesWebSocket(modelID) throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) } -export const chatModel: ProviderPackage.Definition["model"] = (modelID, settings) => - configure(config(settings)).chat(modelID) +export const chatModel: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => configure(config(settings)).chat(modelID) export const responses = provider.responses export const responsesWebSocket = provider.responsesWebSocket export const chat = provider.chat diff --git a/packages/ai/test/provider-options/openai.types.ts b/packages/ai/test/provider-options/openai.types.ts new file mode 100644 index 0000000000..9546e1271b --- /dev/null +++ b/packages/ai/test/provider-options/openai.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { OpenAI } from "../../src/providers" + +const model = OpenAI.responses("gpt-5") + +LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error OpenAI reasoning effort must be a string. + providerOptions: { openai: { reasoningEffort: 1 } }, +}) From b4ac939537f57157ca7f99e36a6198de4701f9d4 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:19:44 +0530 Subject: [PATCH 09/51] feat(ai): type xAI request options (#39505) --- packages/ai/src/providers/xai.ts | 8 +++++--- packages/ai/test/provider-options/xai.types.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 packages/ai/test/provider-options/xai.types.ts diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts index c5d3220ee7..37aae83a52 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -5,12 +5,14 @@ import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import * as OpenAIResponses from "../protocols/openai-responses" import { XAIImages } from "../protocols/xai-images" +import type { OpenAIProviderOptionsInput } from "./openai-options" export const id = ProviderID.make("xai") -export type ModelOptions = RouteDefaultsInput & +export type ModelOptions = Omit & ProviderAuthOption<"optional"> & { readonly baseURL?: string + readonly providerOptions?: OpenAIProviderOptionsInput } export type { XAIImageOptions } from "../protocols/xai-images" @@ -42,8 +44,8 @@ const configuredChatRoute = (input: ModelOptions) => { export const configure = (input: ModelOptions = {}) => { const responsesRoute = configuredResponsesRoute(input) const chatRoute = configuredChatRoute(input) - const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID }) - const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID }) + const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID }) + const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID }) const image = (modelID: string | ModelID) => XAIImages.model({ id: modelID, diff --git a/packages/ai/test/provider-options/xai.types.ts b/packages/ai/test/provider-options/xai.types.ts new file mode 100644 index 0000000000..619f543083 --- /dev/null +++ b/packages/ai/test/provider-options/xai.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { XAI } from "../../src/providers" + +const model = XAI.provider.model("grok-4") + +LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string. + providerOptions: { openai: { reasoningEffort: true } }, +}) From cb80f47112ad8e533fd5fec5e086e83bc00de91f Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:20:04 +0530 Subject: [PATCH 10/51] feat(ai): type Vertex Messages request options (#39501) --- packages/ai/src/providers/google-vertex-messages.ts | 7 +++++-- .../google-vertex-messages.types.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 packages/ai/test/provider-options/google-vertex-messages.types.ts diff --git a/packages/ai/src/providers/google-vertex-messages.ts b/packages/ai/src/providers/google-vertex-messages.ts index 53c4e16b0d..9dbbc441a1 100644 --- a/packages/ai/src/providers/google-vertex-messages.ts +++ b/packages/ai/src/providers/google-vertex-messages.ts @@ -91,7 +91,7 @@ export const configure = (input: Config = {}) => { const route = configuredRoute(input) return { id, - model: (modelID: string | ModelID) => route.model({ id: modelID }), + model: (modelID: string | ModelID) => route.model({ id: modelID }), configure, } } @@ -101,7 +101,10 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { +export const model: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => { if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys") return configure({ accessToken: settings.accessToken, diff --git a/packages/ai/test/provider-options/google-vertex-messages.types.ts b/packages/ai/test/provider-options/google-vertex-messages.types.ts new file mode 100644 index 0000000000..040a4e03da --- /dev/null +++ b/packages/ai/test/provider-options/google-vertex-messages.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { GoogleVertexMessages } from "../../src/providers" + +const model = GoogleVertexMessages.configure({ accessToken: "test", project: "project" }).model("claude") + +LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { effort: "medium" } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error Vertex Anthropic effort must be a string. + providerOptions: { anthropic: { effort: false } }, +}) From ce2c9e7e2682d343735a7342a454f84ab09c5bd8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:20:30 +0530 Subject: [PATCH 11/51] feat(ai): type Google request options (#39504) --- packages/ai/src/providers/google.ts | 4 ++-- .../ai/test/provider-options/google.types.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 packages/ai/test/provider-options/google.types.ts diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 2c3476117c..89da1a923a 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -50,14 +50,14 @@ export const configure = (input: Config = {}) => { }) return { id, - model: (modelID: string | ModelID) => route.model({ id: modelID }), + model: (modelID: string | ModelID) => route.model({ id: modelID }), image, configure, } } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => configure({ apiKey: settings.apiKey, baseURL: settings.baseURL, diff --git a/packages/ai/test/provider-options/google.types.ts b/packages/ai/test/provider-options/google.types.ts new file mode 100644 index 0000000000..3ccfdfb1a5 --- /dev/null +++ b/packages/ai/test/provider-options/google.types.ts @@ -0,0 +1,17 @@ +import { LLM } from "../../src" +import { Google } from "../../src/providers" + +const model = Google.provider.model("gemini-2.5-pro") + +LLM.request({ + model, + prompt: "Hello", + providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } }, +}) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error Gemini thinking budgets must be numeric. + providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } }, +}) From 224feff7c4b50e8e4a07f04ced7ae137273bd09f Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:20:55 +0530 Subject: [PATCH 12/51] feat(ai): type compatible Responses options (#39506) --- .../ai/src/providers/openai-compatible-responses.ts | 7 +++++-- .../openai-compatible-responses.types.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 packages/ai/test/provider-options/openai-compatible-responses.types.ts diff --git a/packages/ai/src/providers/openai-compatible-responses.ts b/packages/ai/src/providers/openai-compatible-responses.ts index 38293eb1d8..fe2fc49bc5 100644 --- a/packages/ai/src/providers/openai-compatible-responses.ts +++ b/packages/ai/src/providers/openai-compatible-responses.ts @@ -36,7 +36,7 @@ export const configure = (input: Config) => { }) return { id: ProviderID.make(provider), - model: (modelID: string | ModelID) => route.model({ id: modelID }), + model: (modelID: string | ModelID) => route.model({ id: modelID }), configure, } } @@ -46,7 +46,10 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => +export const model: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => configure({ apiKey: settings.apiKey, baseURL: settings.baseURL, diff --git a/packages/ai/test/provider-options/openai-compatible-responses.types.ts b/packages/ai/test/provider-options/openai-compatible-responses.types.ts new file mode 100644 index 0000000000..cb178b3b97 --- /dev/null +++ b/packages/ai/test/provider-options/openai-compatible-responses.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { OpenAICompatibleResponses } from "../../src/providers" + +const model = OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("model") + +LLM.request({ model, prompt: "Hello", providerOptions: { openresponses: { reasoningSummary: "detailed" } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error Open Responses reasoning summaries use a fixed union. + providerOptions: { openresponses: { reasoningSummary: "full" } }, +}) From f5cdf0f056fb4be181b44e3a6517bef57ed762cf Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:21:19 +0530 Subject: [PATCH 13/51] feat(ai): type Anthropic request options (#39502) --- packages/ai/src/providers/anthropic.ts | 5 ++++- .../ai/test/provider-options/anthropic.types.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 packages/ai/test/provider-options/anthropic.types.ts diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index eb175ec50c..d8e57be1b3 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -52,7 +52,10 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { +export const model: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => { if (settings.apiKey !== undefined && settings.authToken !== undefined) throw new Error("Anthropic apiKey cannot be combined with authToken") return configure({ diff --git a/packages/ai/test/provider-options/anthropic.types.ts b/packages/ai/test/provider-options/anthropic.types.ts new file mode 100644 index 0000000000..2d6c104094 --- /dev/null +++ b/packages/ai/test/provider-options/anthropic.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { Anthropic } from "../../src/providers" + +const model = Anthropic.provider.model("claude-sonnet-4-5") + +LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { thinking: { type: "adaptive" } } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error Anthropic thinking modes are a fixed union. + providerOptions: { anthropic: { thinking: { type: "automatic" } } }, +}) From 3b8299e3f2837d7617ecf3ff47ec393bf1ec41a9 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:21:43 +0530 Subject: [PATCH 14/51] feat(ai): type Azure request options (#39498) --- packages/ai/src/providers/azure.ts | 20 +++++++++++++------ .../ai/test/provider-options/azure.types.ts | 13 ++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 packages/ai/test/provider-options/azure.types.ts diff --git a/packages/ai/src/providers/azure.ts b/packages/ai/src/providers/azure.ts index dd0691a539..d4c916a588 100644 --- a/packages/ai/src/providers/azure.ts +++ b/packages/ai/src/providers/azure.ts @@ -99,10 +99,14 @@ export const configure = (input: Config) => { const modelDefaults = defaults(input) const responses = (modelID: string | ModelID) => - configuredResponsesRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID }) + configuredResponsesRoute + .with(withOpenAIOptions(modelID, modelDefaults)) + .model({ id: modelID }) const chat = (modelID: string | ModelID) => - configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID }) + configuredChatRoute + .with(withOpenAIOptions(modelID, modelDefaults)) + .model({ id: modelID }) return { id, @@ -133,8 +137,12 @@ const config = (settings: Settings): Config => { throw new Error("Azure requires resourceName or baseURL") } -export const responsesModel: ProviderPackage.Definition["model"] = (modelID, settings) => - configure(config(settings)).responses(modelID) -export const chatModel: ProviderPackage.Definition["model"] = (modelID, settings) => - configure(config(settings)).chat(modelID) +export const responsesModel: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => configure(config(settings)).responses(modelID) +export const chatModel: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => configure(config(settings)).chat(modelID) export const model = responsesModel diff --git a/packages/ai/test/provider-options/azure.types.ts b/packages/ai/test/provider-options/azure.types.ts new file mode 100644 index 0000000000..8441f5528a --- /dev/null +++ b/packages/ai/test/provider-options/azure.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { Azure } from "../../src/providers" + +const model = Azure.configure({ resourceName: "example" }).responses("deployment") + +LLM.request({ model, prompt: "Hello", providerOptions: { openai: { store: false } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error Azure OpenAI store must be boolean. + providerOptions: { openai: { store: "false" } }, +}) From 9038e44a68e4cea68ffea429a6919916224475d7 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:22:01 +0530 Subject: [PATCH 15/51] feat(ai): type Copilot request options (#39496) --- packages/ai/src/providers/github-copilot.ts | 6 ++++-- .../test/provider-options/github-copilot.types.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 packages/ai/test/provider-options/github-copilot.types.ts diff --git a/packages/ai/src/providers/github-copilot.ts b/packages/ai/src/providers/github-copilot.ts index cc776d3b15..ff75d0a776 100644 --- a/packages/ai/src/providers/github-copilot.ts +++ b/packages/ai/src/providers/github-copilot.ts @@ -50,9 +50,11 @@ export const configure = (options: ModelOptions) => { const responsesRoute = configuredResponsesRoute(options) const chatRoute = configuredChatRoute(options) const responses = (modelID: string | ModelID) => - responsesRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID }) + responsesRoute + .with(withOpenAIOptions(modelID, defaults(options))) + .model({ id: modelID }) const chat = (modelID: string | ModelID) => - chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID }) + chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID }) return { id, model: (modelID: string | ModelID) => diff --git a/packages/ai/test/provider-options/github-copilot.types.ts b/packages/ai/test/provider-options/github-copilot.types.ts new file mode 100644 index 0000000000..6f1ea24432 --- /dev/null +++ b/packages/ai/test/provider-options/github-copilot.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { GitHubCopilot } from "../../src/providers" + +const model = GitHubCopilot.configure({ baseURL: "https://example.com" }).model("gpt-5") + +LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningSummary: "auto" } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error Copilot reasoning summaries use the OpenAI union. + providerOptions: { openai: { reasoningSummary: "full" } }, +}) From fea17b4a0ec5dfcc8f1463ffcca935103615635d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:22:16 +0530 Subject: [PATCH 16/51] feat(ai): type Cloudflare request options (#39507) --- packages/ai/src/providers/cloudflare.ts | 14 ++++++++++---- .../ai/test/provider-options/cloudflare.types.ts | 13 +++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 packages/ai/test/provider-options/cloudflare.types.ts diff --git a/packages/ai/src/providers/cloudflare.ts b/packages/ai/src/providers/cloudflare.ts index a006152e98..a8e911feef 100644 --- a/packages/ai/src/providers/cloudflare.ts +++ b/packages/ai/src/providers/cloudflare.ts @@ -4,6 +4,7 @@ import { Auth } from "../route/auth" import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" import type { RouteDefaultsInput } from "../route/client" import { ProviderID, type ModelID } from "../schema" +import type { OpenAIProviderOptionsInput } from "./openai-options" export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway") export const workersAIID = ProviderID.make("cloudflare-workers-ai") @@ -20,10 +21,11 @@ type GatewayURL = AtLeastOne<{ } export type AIGatewayOptions = GatewayURL & - RouteDefaultsInput & + Omit & ProviderAuthOption<"optional"> & { /** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */ readonly gatewayApiKey?: CloudflareSecret + readonly providerOptions?: OpenAIProviderOptionsInput } type WorkersAIURL = AtLeastOne<{ @@ -31,7 +33,11 @@ type WorkersAIURL = AtLeastOne<{ readonly baseURL: string }> -export type WorkersAIOptions = WorkersAIURL & RouteDefaultsInput & ProviderAuthOption<"optional"> +export type WorkersAIOptions = WorkersAIURL & + Omit & + ProviderAuthOption<"optional"> & { + readonly providerOptions?: OpenAIProviderOptionsInput + } export const aiGatewayBaseURL = (input: GatewayURL) => { if (input.baseURL) return input.baseURL @@ -98,7 +104,7 @@ const configureAIGateway = (options: AIGatewayOptions) => { }) return { id: aiGatewayID, - model: (modelID: string | ModelID) => route.model({ id: modelID }), + model: (modelID: string | ModelID) => route.model({ id: modelID }), configure: configureAIGateway, } } @@ -111,7 +117,7 @@ const configureWorkersAI = (options: WorkersAIOptions) => { }) return { id: workersAIID, - model: (modelID: string | ModelID) => route.model({ id: modelID }), + model: (modelID: string | ModelID) => route.model({ id: modelID }), configure: configureWorkersAI, } } diff --git a/packages/ai/test/provider-options/cloudflare.types.ts b/packages/ai/test/provider-options/cloudflare.types.ts new file mode 100644 index 0000000000..6e2af83bae --- /dev/null +++ b/packages/ai/test/provider-options/cloudflare.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { CloudflareWorkersAI } from "../../src/providers" + +const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model") + +LLM.request({ model, prompt: "Hello", providerOptions: { openai: { promptCacheKey: "cache" } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error Cloudflare's OpenAI-compatible prompt cache key must be a string. + providerOptions: { openai: { promptCacheKey: 1 } }, +}) From d72b4280611e56206a48883624df8b769942ceb5 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:22:41 +0530 Subject: [PATCH 17/51] feat(ai): type Vertex Chat request options (#39503) --- packages/ai/src/providers/google-vertex-chat.ts | 10 ++++++---- .../provider-options/google-vertex-chat.types.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 packages/ai/test/provider-options/google-vertex-chat.types.ts diff --git a/packages/ai/src/providers/google-vertex-chat.ts b/packages/ai/src/providers/google-vertex-chat.ts index e2f9c1a415..eea7b2b8e5 100644 --- a/packages/ai/src/providers/google-vertex-chat.ts +++ b/packages/ai/src/providers/google-vertex-chat.ts @@ -1,8 +1,9 @@ import type { ProviderPackage } from "../provider-package" import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat" import type { RouteDefaultsInput } from "../route/client" -import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import { ProviderID, type ModelID } from "../schema" import { GoogleVertexShared } from "./google-vertex-shared" +import type { OpenAIProviderOptionsInput } from "./openai-options" export const id = ProviderID.make("google-vertex") @@ -11,6 +12,7 @@ export type Config = RouteDefaultsInput & readonly baseURL?: string readonly location?: string readonly project?: string + readonly providerOptions?: OpenAIProviderOptionsInput } export interface Settings extends ProviderPackage.Settings { @@ -19,7 +21,7 @@ export interface Settings extends ProviderPackage.Settings { readonly baseURL?: string readonly location?: string readonly project?: string - readonly providerOptions?: ProviderOptions + readonly providerOptions?: OpenAIProviderOptionsInput } const route = OpenAICompatibleChat.route.with({ @@ -56,7 +58,7 @@ export const configure = (input: Config = {}) => { const route = configuredRoute(input) return { id, - model: (modelID: string | ModelID) => route.model({ id: modelID }), + model: (modelID: string | ModelID) => route.model({ id: modelID }), configure, } } @@ -66,7 +68,7 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys") return configure({ accessToken: settings.accessToken, diff --git a/packages/ai/test/provider-options/google-vertex-chat.types.ts b/packages/ai/test/provider-options/google-vertex-chat.types.ts new file mode 100644 index 0000000000..d64f0af14b --- /dev/null +++ b/packages/ai/test/provider-options/google-vertex-chat.types.ts @@ -0,0 +1,13 @@ +import { LLM } from "../../src" +import { GoogleVertexChat } from "../../src/providers" + +const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini") + +LLM.request({ model, prompt: "Hello", providerOptions: { openai: { serviceTier: "priority" } } }) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union. + providerOptions: { openai: { serviceTier: "premium" } }, +}) From 9554f9a16eff6773a16b03082b1852a2d0e9cd2f Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 29 Jul 2026 18:23:00 +0530 Subject: [PATCH 18/51] feat(ai): type Vertex request options (#39499) --- packages/ai/src/providers/google-vertex.ts | 8 ++++++-- .../provider-options/google-vertex.types.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 packages/ai/test/provider-options/google-vertex.types.ts diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 78f4e0764d..78037a2828 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -77,7 +77,8 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => { export const configure = (input: Config = {}) => { return { id, - model: (modelID: string | ModelID) => configuredRoute(input, modelID).model({ id: modelID }), + model: (modelID: string | ModelID) => + configuredRoute(input, modelID).model({ id: modelID }), configure, } } @@ -86,7 +87,10 @@ export const provider = { id, configure, } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { +export const model: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => { if (settings.apiKey !== undefined && settings.accessToken !== undefined) throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth") return configure({ diff --git a/packages/ai/test/provider-options/google-vertex.types.ts b/packages/ai/test/provider-options/google-vertex.types.ts new file mode 100644 index 0000000000..6098092f89 --- /dev/null +++ b/packages/ai/test/provider-options/google-vertex.types.ts @@ -0,0 +1,17 @@ +import { LLM } from "../../src" +import { GoogleVertex } from "../../src/providers" + +const model = GoogleVertex.provider.configure({ apiKey: "test" }).model("gemini-2.5-pro") + +LLM.request({ + model, + prompt: "Hello", + providerOptions: { gemini: { thinkingConfig: { includeThoughts: true } } }, +}) + +LLM.request({ + model, + prompt: "Hello", + // @ts-expect-error Vertex Gemini includeThoughts must be boolean. + providerOptions: { gemini: { thinkingConfig: { includeThoughts: "yes" } } }, +}) From 2a85c861e02f484576013542fd8ff88b41749268 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 28 Jul 2026 22:32:06 -0400 Subject: [PATCH 19/51] fix(session): hide pending admission sequence --- packages/app/src/utils/server-compat.test.ts | 1 - packages/app/src/utils/server-compat.ts | 3 --- packages/cli/test/run/noninteractive.test.ts | 2 +- .../client/src/promise/generated/types.ts | 10 +------- packages/client/test/effect.test.ts | 2 -- packages/client/test/promise.test.ts | 4 ---- packages/core/src/session/pending.ts | 5 ---- packages/core/test/plugin/promise.test.ts | 1 - packages/core/test/session-create.test.ts | 1 - packages/schema/src/session-pending.ts | 3 +-- packages/schema/test/contract-hygiene.test.ts | 23 +++++++++++++++++++ packages/tui/src/context/data.tsx | 2 -- packages/tui/src/mini/stream-v2.transport.ts | 4 +--- packages/tui/src/mini/types.ts | 1 - packages/tui/test/cli/tui/data.test.tsx | 3 --- packages/tui/test/mini/footer.view.test.tsx | 2 -- .../tui/test/mini/stream-v2.transport.test.ts | 13 ++++------- 17 files changed, 32 insertions(+), 48 deletions(-) diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts index 52e5ec6e3b..1330f846ec 100644 --- a/packages/app/src/utils/server-compat.test.ts +++ b/packages/app/src/utils/server-compat.test.ts @@ -26,7 +26,6 @@ function setup( return new Response(undefined, { status: 204 }) if (request.method === "POST" && request.url.endsWith("/prompt")) { return Response.json({ - admittedSeq: 1, id: "msg_1", sessionID: "ses_1", timeCreated: 1, diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index 1df1338b71..b37bf6db48 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -229,7 +229,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi { ], }) return { - admittedSeq: 0, id: value.id ?? "", sessionID: value.sessionID, timeCreated: Date.now(), @@ -255,7 +254,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi { })), }) return { - admittedSeq: 0, id: value.id ?? "", sessionID: value.sessionID, timeCreated: Date.now(), @@ -280,7 +278,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi { modelID: value.model.modelID, }) return { - admittedSeq: 0, id: value.id ?? "", sessionID: value.sessionID, timeCreated: Date.now(), diff --git a/packages/cli/test/run/noninteractive.test.ts b/packages/cli/test/run/noninteractive.test.ts index 8527bcaa57..c8a57d3637 100644 --- a/packages/cli/test/run/noninteractive.test.ts +++ b/packages/cli/test/run/noninteractive.test.ts @@ -267,7 +267,7 @@ async function run(input: { values.push(...input.turn(messageID)) wake?.() wake = undefined - return ok({ admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never + return ok({ id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never }) await runNonInteractivePrompt({ client: sdk, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index fcd25cc09b..4f6dad595b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -43,13 +43,7 @@ export type PromptMention = { start: number; end: number; text: string } export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } } -export type SessionPendingCompaction = { - admittedSeq: number - id: string - sessionID: string - timeCreated: number - type: "compaction" -} +export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" } export type SessionMessageAgentSelected = { id: string @@ -1210,7 +1204,6 @@ export type PromptFileAttachment = { export type PromptAgentAttachment = { name: string; mention?: PromptMention } export type SessionPendingSynthetic = { - admittedSeq: number id: string sessionID: string timeCreated: number @@ -1966,7 +1959,6 @@ export type AgentInfo = { export type SessionsResponse = { data: Array; cursor: { previous?: string | null; next?: string | null } } export type SessionPendingUser = { - admittedSeq: number id: string sessionID: string timeCreated: number diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index b52d86d382..eee0d2d08a 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -268,7 +268,6 @@ const session = { const admission = { data: { - admittedSeq: 0, id: "msg_test", sessionID: "ses_test", type: "user", @@ -281,7 +280,6 @@ const admission = { const compactionAdmission = { data: { type: "compaction", - admittedSeq: 1, id: "msg_compaction", sessionID: "ses_test", timeCreated: 1_717_171_717_000, diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index c3c0512f17..b38b141cbc 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -304,7 +304,6 @@ test("session.pending.list uses the public HTTP contract", async () => { const requests: Array<{ method: string; url: string }> = [] const pending = [ { - admittedSeq: 3, id: "msg_pending", sessionID: "ses_test", timeCreated: 1_717_171_717_000, @@ -547,7 +546,6 @@ const session = { const admission = { data: { - admittedSeq: 0, id: "msg_test", sessionID: "ses_test", type: "user", @@ -559,7 +557,6 @@ const admission = { const syntheticAdmission = { data: { - admittedSeq: 1, id: "msg_synthetic", sessionID: "ses_test", type: "synthetic", @@ -572,7 +569,6 @@ const syntheticAdmission = { const compactionAdmission = { data: { type: "compaction", - admittedSeq: 1, id: "msg_compaction", sessionID: "ses_test", timeCreated: 1_717_171_717_000, diff --git a/packages/core/src/session/pending.ts b/packages/core/src/session/pending.ts index 47d74d0abf..986d624a6f 100644 --- a/packages/core/src/session/pending.ts +++ b/packages/core/src/session/pending.ts @@ -53,7 +53,6 @@ export class LifecycleConflict extends Schema.TaggedErrorClass { const base = { - admittedSeq: row.admitted_seq, id: SessionMessage.ID.make(row.id), sessionID: SessionSchema.ID.make(row.session_id), timeCreated: DateTime.makeUnsafe(row.time_created), @@ -134,7 +133,6 @@ const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(func const decoded = decodeAdmittedEvent(row.data) if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue const base = { - admittedSeq: row.seq, id, sessionID, timeCreated: DateTime.makeUnsafe(row.created), @@ -172,10 +170,7 @@ export const admit = Effect.fn("SessionPending.admit")(function* ( }) .pipe( Effect.flatMap((event) => { - if (event.durable === undefined) - return Effect.die(new Error("Session input admission event is missing aggregate sequence")) const base = { - admittedSeq: event.durable.seq, id: request.id, sessionID: request.sessionID, timeCreated: event.created, diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 064e083ac7..f63c7f3eca 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -62,7 +62,6 @@ describe("fromPromise", () => { seen = value return Effect.succeed( SessionPending.Synthetic.make({ - admittedSeq: 1, id: SessionMessage.ID.make(input.id), sessionID: Session.ID.make(input.sessionID), timeCreated: DateTime.makeUnsafe(0), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index ae048e228b..968cb94713 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -484,7 +484,6 @@ describe("Session.create", () => { type: "user", data: { text: "Replay lifecycle" }, delivery: "steer", - admittedSeq: 1, }) expect(yield* store.context(created.id)).toEqual([]) diff --git a/packages/schema/src/session-pending.ts b/packages/schema/src/session-pending.ts index 215c13c299..f687c4ecf2 100644 --- a/packages/schema/src/session-pending.ts +++ b/packages/schema/src/session-pending.ts @@ -3,7 +3,7 @@ export * as SessionPending from "./session-pending.js" import { Schema } from "effect" import { optional } from "./schema.js" import { Prompt } from "./prompt.js" -import { DateTimeUtcFromMillis, NonNegativeInt } from "./schema.js" +import { DateTimeUtcFromMillis } from "./schema.js" import { SessionDelivery } from "./session-delivery.js" import { SessionID } from "./session-id.js" import { SessionMessage } from "./session-message.js" @@ -45,7 +45,6 @@ export const Message = Schema.Union([UserMessage, SyntheticMessage]).pipe( export type Message = typeof Message.Type const Admitted = { - admittedSeq: NonNegativeInt, id: SessionMessage.ID, sessionID: SessionID, timeCreated: DateTimeUtcFromMillis, diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index f9b379e8e0..ba5496e660 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -54,6 +54,29 @@ describe("contract hygiene", () => { ).toEqual({ text: "completed" }) }) + test("pending session items omit the internal admission sequence", () => { + expect( + Schema.encodeSync(SessionPending.Info)( + Schema.decodeUnknownSync(SessionPending.Info)({ + admittedSeq: 3, + id: "msg_pending", + sessionID: "ses_pending", + timeCreated: 1, + type: "user", + data: { text: "hello" }, + delivery: "steer", + }), + ), + ).toEqual({ + id: "msg_pending", + sessionID: "ses_pending", + timeCreated: 1, + type: "user", + data: { text: "hello" }, + delivery: "steer", + }) + }) + test("forms require at least one field", () => { expect(() => Schema.decodeUnknownSync(Form.Info)({ diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 01a60f57f1..5880275790 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -407,7 +407,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ addPending({ id: event.data.inputID, sessionID: event.data.sessionID, - admittedSeq: event.durable.seq, timeCreated: event.created, ...event.data.input, }) @@ -702,7 +701,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ addPending({ id: event.data.inputID, sessionID: event.data.sessionID, - admittedSeq: event.durable.seq, timeCreated: event.created, type: "compaction", }) diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index 09462654e3..e58811ec20 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -186,7 +186,6 @@ function pendingPrompt(item: SessionPendingInfo): FooterQueuedPrompt | undefined messageID: item.id, prompt: { messageID: item.id, text: item.data.text, parts: [] }, delivery: item.delivery, - admittedSeq: item.admittedSeq, } } @@ -517,7 +516,7 @@ export async function createSessionTransport(input: StreamInput): Promise { - const prompts = [...state.pending.values()].toSorted((left, right) => left.admittedSeq - right.admittedSeq) + const prompts = [...state.pending.values()] input.trace?.write("ui.patch", { pending: prompts.length }) input.footer.event({ type: "queued.prompts", prompts }) } @@ -905,7 +904,6 @@ export async function createSessionTransport(input: StreamInput): Promise { const sessionID = "session-compaction-queued" let pending = [ { - admittedSeq: 3, id: "message-compaction-queued", sessionID, timeCreated: 1, type: "compaction" as const, }, { - admittedSeq: 4, id: "message-compaction-later", sessionID, timeCreated: 2, @@ -2474,7 +2472,6 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn { id: messageID, sessionID, - admittedSeq: 0, timeCreated: 0, type: "user", data: { text: "hello" }, diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 8e44e01eba..50e81d168d 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -919,7 +919,6 @@ test("direct pending panel shows durable delivery without edit actions", async ( messageID: "m-1", prompt: { text: "fix the auth test", parts: [] }, delivery: "queue" as const, - admittedSeq: 1, }, ]) @@ -1284,7 +1283,6 @@ test("direct footer shows authoritative pending work while running", async () => messageID: "m-queued", prompt: { text: "follow up", parts: [] }, delivery: "queue", - admittedSeq: 1, }, ]} theme={() => RUN_THEME_FALLBACK} diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index f3c25ea52c..2a1f828e28 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -77,7 +77,6 @@ function durable(sessionID: string, seq = 0, version: 1 | 2 = 1) { function promptAdmission(input: Parameters[0], sessionID = "ses_1") { return { - admittedSeq: 1, id: input.id ?? "msg_prompt", sessionID, type: "user" as const, @@ -659,7 +658,6 @@ describe("V2 mini transport", () => { pending: { ses_1: [ { - admittedSeq: 1, id: "msg_queued", sessionID: "ses_1", timeCreated: 1, @@ -680,9 +678,9 @@ describe("V2 mini transport", () => { const pending = () => ui.events .findLast((item) => item.type === "queued.prompts") - ?.prompts.map((item) => [item.messageID, item.delivery, item.admittedSeq]) + ?.prompts.map((item) => [item.messageID, item.delivery]) - expect(pending()).toEqual([["msg_queued", "queue", 1]]) + expect(pending()).toEqual([["msg_queued", "queue"]]) events.push({ id: "evt_promoted", created: 2, @@ -697,7 +695,7 @@ describe("V2 mini transport", () => { ) expect(pending()).toEqual([]) const prompt = spyOn(client.session, "prompt").mockImplementation( - (request) => ok({ ...promptAdmission(request), admittedSeq: 2 }) as never, + (request) => ok(promptAdmission(request)) as never, ) await transport.queuePromptTurn({ agent: "review", @@ -726,8 +724,8 @@ describe("V2 mini transport", () => { await Bun.sleep(0) } expect(pending()).toEqual([ - ["msg_earlier", "steer", 1], - ["msg_next", "queue", 2], + ["msg_next", "queue"], + ["msg_earlier", "steer"], ]) await transport.close() }) @@ -2694,7 +2692,6 @@ describe("V2 mini transport", () => { }) }) return ok({ - admittedSeq: 1, id: input.id ?? "msg_cmd", sessionID: "ses_1", type: "user" as const, From fc11ed38388b37e05b7262a9160248e13f19d66d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 09:49:35 -0400 Subject: [PATCH 20/51] feat(session): define explicit fork boundaries --- packages/app/src/components/dialog-fork.tsx | 2 +- packages/cli/src/acp/service.ts | 5 +- packages/cli/src/session-target.ts | 10 +- packages/client/src/effect/api/api.ts | 8 +- .../client/src/effect/generated/client.ts | 2 +- .../client/src/promise/generated/client.ts | 2 +- .../client/src/promise/generated/types.ts | 10 +- packages/core/schema.json | 16 +- packages/core/src/database/migration.gen.ts | 1 + .../20260729022634_session_fork_boundary.ts | 13 + packages/core/src/database/schema.gen.ts | 3 +- packages/core/src/session.ts | 47 ++-- packages/core/src/session/error.ts | 8 + packages/core/src/session/info.ts | 14 +- .../core/src/session/instruction-state.ts | 103 ++++--- packages/core/src/session/projector.ts | 39 +-- packages/core/src/session/sql.ts | 3 +- packages/core/test/session-create.test.ts | 34 ++- packages/core/test/session-runner.test.ts | 20 +- packages/protocol/openapi.json | 262 +++++++++++++----- packages/protocol/src/groups/session.ts | 7 +- packages/schema/src/session-event.ts | 5 +- packages/schema/src/session-fork.ts | 16 ++ packages/schema/src/session.ts | 9 +- packages/server/src/handlers/session.ts | 6 +- packages/tui/src/app.tsx | 4 +- .../tui/src/routes/session/dialog-fork.tsx | 34 +-- packages/www/openapi.json | 262 +++++++++++++----- packages/www/public/openapi.json | 262 +++++++++++++----- 29 files changed, 821 insertions(+), 386 deletions(-) create mode 100644 packages/core/src/database/migration/20260729022634_session_fork_boundary.ts create mode 100644 packages/schema/src/session-fork.ts diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 5187d980ea..91d3490732 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -69,7 +69,7 @@ export const DialogFork: Component = () => { const dir = base64Encode(sdk().directory) sdk() - .api.session.fork({ sessionID, messageID: item.id }) + .api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } }) .then((forked) => { dialog.close() prompt.set(restored, undefined, { dir, id: forked.id }) diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index 963b5c3ed4..897d88c8d2 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -244,7 +244,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti return {} }, forkSession: async (params) => { - const forked = await input.client.session.fork({ sessionID: params.sessionId }) + const forked = await input.client.session.fork({ + sessionID: params.sessionId, + boundary: { type: "through" }, + }) const state = await attach(forked, forked.location.directory, params.mcpServers ?? []) await replay(state) return { sessionId: state.id, configOptions: configOptions(state) } diff --git a/packages/cli/src/session-target.ts b/packages/cli/src/session-target.ts index 975f4829b2..4bd670a85d 100644 --- a/packages/cli/src/session-target.ts +++ b/packages/cli/src/session-target.ts @@ -105,7 +105,7 @@ async function selectSession(input: { return { session: input.fork ? await input.client.session - .fork({ sessionID: explicit.id }, ...requestOptions(input.signal)) + .fork({ sessionID: explicit.id, boundary: { type: "through" } }, ...requestOptions(input.signal)) .catch((error) => { throw new SessionTargetMutationError(error) }) @@ -118,9 +118,11 @@ async function selectSession(input: { if (!selected) return { session: undefined, location } return { session: input.fork - ? await input.client.session.fork({ sessionID: selected.id }, ...requestOptions(input.signal)).catch((error) => { - throw new SessionTargetMutationError(error) - }) + ? await input.client.session + .fork({ sessionID: selected.id, boundary: { type: "through" } }, ...requestOptions(input.signal)) + .catch((error) => { + throw new SessionTargetMutationError(error) + }) : selected, } } diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 3bb65cc007..80df5a87e4 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -136,7 +136,7 @@ export type Endpoint5_4Input = { readonly sessionID: Session.ID } export type Endpoint5_4Output = void export type SessionRemoveOperation = (input: Endpoint5_4Input) => Effect.Effect -export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly messageID?: SessionMessage.ID | undefined } +export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary } export type Endpoint5_5Output = Session.Info export type SessionForkOperation = (input: Endpoint5_5Input) => Effect.Effect @@ -342,8 +342,10 @@ export type Endpoint5_26Output = readonly data: { readonly sessionID: Session.ID readonly parentID: Session.ID - readonly parentSeq: number - readonly from?: SessionMessage.ID | undefined + readonly boundary: Session.ForkBoundary + readonly instructions?: + | { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> } + | undefined } } | { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 8b457db56f..7706c9aa9b 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -329,7 +329,7 @@ const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Inp const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) => preserveEffect()( - raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"] } }).pipe( + raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index e6edc8a856..aa0563f948 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -510,7 +510,7 @@ export function make(options: ClientOptions) { { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`, - body: { messageID: input["messageID"] }, + body: { boundary: input["boundary"] }, successStatus: 200, declaredStatuses: [404, 400, 401], empty: false, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 4f6dad595b..9868099438 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -14,6 +14,8 @@ export type PermissionEffect = "allow" | "deny" | "ask" export type PluginInfo = { id: string } +export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string } + export type MoneyUSD = number export type TokenUsageInfo = { @@ -622,7 +624,7 @@ export type SessionForked = { type: "session.forked" durable: { aggregateID: string; seq: number; version: 2 } location?: LocationRef - data: { sessionID: string; parentID: string; parentSeq: number; from?: string } + data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } } } export type SessionInputPromoted = { @@ -1748,7 +1750,7 @@ export type PermissionRuleset = Array export type SessionInfo = { id: string parentID?: string - fork?: { sessionID: string; messageID?: string } + fork?: { sessionID: string; boundary: SessionForkBoundary } projectID: string agent?: string model?: ModelRef @@ -2694,7 +2696,9 @@ export type SessionRemoveOutput = void export type SessionForkInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly messageID?: { readonly messageID?: string | undefined }["messageID"] + readonly boundary: { + readonly boundary: { readonly type: "before"; readonly messageID: string } | { readonly type: "through" } + }["boundary"] } export type SessionForkOutput = { data: SessionInfo }["data"] diff --git a/packages/core/schema.json b/packages/core/schema.json index 2ef980ade3..7ed6583e21 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "a4ba73b4-21bc-41ab-a415-94e2ca38d798", + "id": "db37a97f-9b5e-4c87-be8b-4feace35136c", "prevIds": [ - "5f0a1db8-d4bf-42c3-becb-96b46fe66bed" + "a4ba73b4-21bc-41ab-a415-94e2ca38d798" ], "ddl": [ { @@ -1266,17 +1266,7 @@ "autoincrement": false, "default": null, "generated": null, - "name": "fork_message_id", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "fork_seq", + "name": "fork_boundary", "entityType": "columns", "table": "session" }, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index a7eaed9b73..ee88d30f17 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -57,5 +57,6 @@ export const migrations = ( import("./migration/20260716020354_kv"), import("./migration/20260722011141_delete_tool_progress_events"), import("./migration/20260722170000_canonical_tool_results"), + import("./migration/20260729022634_session_fork_boundary"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260729022634_session_fork_boundary.ts b/packages/core/src/database/migration/20260729022634_session_fork_boundary.ts new file mode 100644 index 0000000000..bf574b4807 --- /dev/null +++ b/packages/core/src/database/migration/20260729022634_session_fork_boundary.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260729022634_session_fork_boundary", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_boundary\` text;`) + yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_message_id\`;`) + yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_seq\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index b92b75e47b..05c63f435d 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -213,8 +213,7 @@ export default { \`workspace_id\` text, \`parent_id\` text, \`fork_session_id\` text, - \`fork_message_id\` text, - \`fork_seq\` integer, + \`fork_boundary\` text, \`slug\` text NOT NULL, \`directory\` text NOT NULL, \`path\` text, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index c2cdbb3ffc..0ac1b5cc62 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -28,11 +28,12 @@ import { fromRow } from "./session/info" import { SessionRunner } from "./session/runner/index" import { SessionStore } from "./session/store" import { SessionExecution } from "./session/execution" -import { MessageDecodeError, NotFoundError } from "./session/error" +import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { LocationServiceMap } from "./location-service-map" import { SessionEvent } from "./session/event" import { SessionPending } from "./session/pending" +import { InstructionState } from "./session/instruction-state" import { SessionGenerate } from "./session/generate" import { Snapshot } from "./snapshot" import { SessionRevert } from "./session/revert" @@ -106,7 +107,7 @@ type CompactInput = { type ForkInput = { sessionID: SessionSchema.ID - messageID?: SessionMessage.ID + boundary: Session.ForkRequestBoundary } export class OperationUnavailableError extends Schema.TaggedErrorClass()( @@ -181,7 +182,9 @@ export interface Interface { readonly data: SessionSchema.Info[] }> readonly create: (input: CreateInput) => Effect.Effect - readonly fork: (input: ForkInput) => Effect.Effect + readonly fork: ( + input: ForkInput, + ) => Effect.Effect readonly get: (sessionID: SessionSchema.ID) => Effect.Effect readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect readonly messages: (input: { @@ -395,25 +398,33 @@ const layer = Layer.effect( }), fork: Effect.fn("Session.fork")(function* (input) { const parent = yield* result.get(input.sessionID) - const boundary = input.messageID - ? yield* db - .select({ seq: SessionMessageTable.seq }) - .from(SessionMessageTable) - .where( - and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)), - ) - .get() - .pipe(Effect.orDie) - : undefined - if (input.messageID && !boundary) - return yield* new MessageNotFoundError({ sessionID: input.sessionID, messageID: input.messageID }) + const boundary = yield* db + .select({ id: SessionMessageTable.id, seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, input.sessionID), + input.boundary.type === "before" ? eq(SessionMessageTable.id, input.boundary.messageID) : undefined, + ), + ) + .orderBy(desc(SessionMessageTable.seq)) + .limit(1) + .get() + .pipe(Effect.orDie) + if (!boundary && input.boundary.type === "before") + return yield* new MessageNotFoundError({ + sessionID: input.sessionID, + messageID: input.boundary.messageID, + }) + if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID }) const sessionID = SessionSchema.ID.create() - const parentSeq = boundary ? boundary.seq - 1 : yield* Bus.latestSequence(db, parent.id) + const instructionThrough = + input.boundary.type === "before" ? boundary.seq - 1 : yield* Bus.latestSequence(db, parent.id) yield* bus.publish(SessionEvent.Forked, { sessionID, parentID: parent.id, - parentSeq, - from: input.messageID, + boundary: { ...input.boundary, messageID: boundary.id }, + instructions: yield* InstructionState.valuesAt(db, parent.id, instructionThrough), }) return yield* result.get(sessionID).pipe(Effect.orDie) }), diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 42a71500ee..940f74cca5 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -10,6 +10,14 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ses sessionID: SessionSchema.ID, }) {} +export class ForkEmptyError extends Schema.TaggedErrorClass()("Session.ForkEmptyError", { + sessionID: SessionSchema.ID, +}) { + override get message() { + return `Cannot fork empty session: ${this.sessionID}` + } +} + export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.MessageDecodeError", { sessionID: SessionSchema.ID, messageID: SessionMessage.ID, diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 83d0297bd2..ed13a4d317 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -8,7 +8,6 @@ import { AbsolutePath, RelativePath } from "../schema" import { Workspace } from "../workspace" import { SessionSchema } from "./schema" import { SessionTable } from "./sql" -import { SessionMessage } from "./message" import { PersistedRevert } from "@opencode-ai/schema/session-revert" import { Money } from "@opencode-ai/schema/money" @@ -20,12 +19,13 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In projectID: Project.ID.make(row.project_id), title: row.title, parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined, - fork: row.fork_session_id - ? { - sessionID: SessionSchema.ID.make(row.fork_session_id), - messageID: row.fork_message_id ? SessionMessage.ID.make(row.fork_message_id) : undefined, - } - : undefined, + fork: + row.fork_session_id && row.fork_boundary + ? { + sessionID: SessionSchema.ID.make(row.fork_session_id), + boundary: row.fork_boundary, + } + : undefined, agent: row.agent ? Agent.ID.make(row.agent) : undefined, model: row.model ? { diff --git a/packages/core/src/session/instruction-state.ts b/packages/core/src/session/instruction-state.ts index 364dfba8a5..119bff62ca 100644 --- a/packages/core/src/session/instruction-state.ts +++ b/packages/core/src/session/instruction-state.ts @@ -10,11 +10,12 @@ import { SessionEvent } from "./event" import { SessionMessage } from "./message" import { Event } from "@opencode-ai/schema/event" import { SessionSchema } from "./schema" -import { InstructionBlobTable, InstructionStateTable, SessionTable } from "./sql" +import { InstructionBlobTable, InstructionStateTable } from "./sql" type DatabaseService = Database.Interface["db"] const decodeInstructionsUpdated = Schema.decodeUnknownSync(SessionEvent.InstructionsUpdated.data) +const decodeForked = Schema.decodeUnknownSync(SessionEvent.Forked.data) export interface Observation extends Instructions.Admission { readonly sessionID: SessionSchema.ID @@ -94,6 +95,26 @@ export const apply = Effect.fn("InstructionState.apply")(function* ( .pipe(Effect.orDie) }) +export const initialize = Effect.fn("InstructionState.initialize")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + seq: number, + values: Instructions.Values, +) { + yield* db + .insert(InstructionStateTable) + .values({ + session_id: sessionID, + epoch_start: seq, + through_seq: seq, + initial_values: values, + current_values: values, + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) +}) + export const advanceEpoch = Effect.fn("InstructionState.advanceEpoch")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, @@ -255,6 +276,14 @@ const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessio return folded ? foldedState(sessionID, folded) : undefined }) +export const valuesAt = Effect.fn("InstructionState.valuesAt")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + through: number, +) { + return fold(yield* instructionEvents(db, sessionID, through))?.current +}) + const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { return yield* db .select({ seq: EventTable.seq }) @@ -324,15 +353,23 @@ const revertedEventType = Bus.versionedType( SessionEvent.RevertEvent.Committed.type, SessionEvent.RevertEvent.Committed.durable.version, ) -const relevantEventTypes = [instructionEventType, compactionEventType, movedEventType, revertedEventType] +const forkedEventType = Bus.versionedType(SessionEvent.Forked.type, SessionEvent.Forked.durable.version) +const relevantEventTypes = [ + forkedEventType, + instructionEventType, + compactionEventType, + movedEventType, + revertedEventType, +] type InstructionEventRow = typeof EventTable.$inferSelect const instructionEvents = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, + through?: number, ): Effect.fn.Return> { - return yield* eventRows(db, sessionID, relevantEventTypes) + return yield* eventRows(db, sessionID, relevantEventTypes, undefined, through) }) const instructionUpdatesAfter = Effect.fnUntraced(function* ( @@ -348,48 +385,22 @@ const eventRows = Effect.fnUntraced(function* ( sessionID: SessionSchema.ID, types: ReadonlyArray, after?: number, -): Effect.fn.Return> { - const segments = (yield* lineage(db, sessionID)).filter( - (segment) => after === undefined || segment.through === undefined || segment.through > after, - ) - return (yield* Effect.forEach(segments, (segment) => - db - .select() - .from(EventTable) - .where( - and( - eq(EventTable.aggregate_id, segment.sessionID), - inArray(EventTable.type, types), - segment.through === undefined ? undefined : lte(EventTable.seq, segment.through), - after === undefined ? undefined : gt(EventTable.seq, after), - ), - ) - .orderBy(asc(EventTable.seq)) - .all() - .pipe(Effect.orDie), - )).flat() -}) - -const lineage = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, through?: number, -): Effect.fn.Return> { - const session = yield* db - .select({ parentID: SessionTable.fork_session_id, forkSeq: SessionTable.fork_seq }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get() +): Effect.fn.Return> { + return yield* db + .select() + .from(EventTable) + .where( + and( + eq(EventTable.aggregate_id, sessionID), + inArray(EventTable.type, types), + after === undefined ? undefined : gt(EventTable.seq, after), + through === undefined ? undefined : lte(EventTable.seq, through), + ), + ) + .orderBy(asc(EventTable.seq)) + .all() .pipe(Effect.orDie) - const inherited = - session?.parentID && session.forkSeq !== null - ? yield* lineage( - db, - session.parentID, - through === undefined ? session.forkSeq : Math.min(session.forkSeq, through), - ) - : [] - return [...inherited, { sessionID, ...(through === undefined ? {} : { through }) }] }) function fold(rows: ReadonlyArray) { @@ -402,6 +413,12 @@ function fold(rows: ReadonlyArray) { } | undefined >((state, row) => { + if (row.type === forkedEventType) { + const instructions = decodeForked(row.data).instructions + return instructions + ? { epochStart: row.seq, throughSeq: row.seq, initial: instructions, current: instructions } + : undefined + } if (row.type === movedEventType || row.type === revertedEventType) return undefined if (row.type === compactionEventType) return state diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 5c88813762..9d0062fee6 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -1,6 +1,6 @@ export * as SessionProjector from "./projector" -import { and, asc, desc, eq, gt, gte, inArray, lt, sql } from "drizzle-orm" +import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm" import { DateTime, Effect, Layer, Schema, Stream } from "effect" import { Database } from "../database/database" import { Bus } from "../bus" @@ -174,25 +174,28 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .get() .pipe(Effect.orDie) if (!parent) return yield* Effect.die(new Error(`Fork parent session not found: ${event.data.parentID}`)) - const boundary = event.data.from - ? yield* db - .select({ seq: SessionMessageTable.seq }) - .from(SessionMessageTable) - .where( - and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.from)), - ) - .get() - .pipe(Effect.orDie) - : undefined - if (event.data.from && !boundary) - return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`)) + const boundary = yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, event.data.parentID), + eq(SessionMessageTable.id, event.data.boundary.messageID), + ), + ) + .get() + .pipe(Effect.orDie) + if (!boundary) + return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.boundary.messageID}`)) const copied = yield* db .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) .where( and( eq(SessionMessageTable.session_id, event.data.parentID), - boundary === undefined ? undefined : lt(SessionMessageTable.seq, boundary.seq), + event.data.boundary.type === "before" + ? lt(SessionMessageTable.seq, boundary.seq) + : lte(SessionMessageTable.seq, boundary.seq), ), ) .orderBy(desc(SessionMessageTable.seq)) @@ -207,8 +210,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( id: event.data.sessionID, parent_id: null, fork_session_id: event.data.parentID, - fork_message_id: event.data.from, - fork_seq: event.data.parentSeq, + fork_boundary: event.data.boundary, project_id: parent.project_id, workspace_id: parent.workspace_id, slug: Slug.create(), @@ -314,8 +316,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( cursor = rows.at(-1)!.seq } - yield* Bus.reserveSequence(db, event.data.sessionID, event.data.parentSeq) - yield* InstructionState.rebuild(db, event.data.sessionID) + if (copiedSeq !== undefined) yield* Bus.reserveSequence(db, event.data.sessionID, copiedSeq) + if (event.data.instructions) + yield* InstructionState.initialize(db, event.data.sessionID, event.durable.seq, event.data.instructions) }) function run(db: DatabaseService, event: MessageEvent) { diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 17654df1b3..9fbe678362 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -32,8 +32,7 @@ export const SessionTable = sqliteTable( workspace_id: text().$type(), parent_id: text().$type(), fork_session_id: text().$type(), - fork_message_id: text().$type(), - fork_seq: integer(), + fork_boundary: text({ mode: "json" }).$type(), slug: text().notNull(), directory: directoryColumn().notNull(), path: pathColumn(), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 968cb94713..1bda9a5488 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -199,7 +199,7 @@ describe("Session.create", () => { yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false }) yield* SessionPending.promote(db, bus, parent.id, "steer") - const forked = yield* session.fork({ sessionID: parent.id }) + const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }) const parentContext = yield* session.context(parent.id) const forkContext = yield* session.context(forked.id) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) @@ -252,6 +252,17 @@ describe("Session.create", () => { }), ) + it.effect("rejects forking an empty session", () => + Effect.gen(function* () { + const session = yield* Session.Service + const parent = yield* session.create({ location }) + + expect( + yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }).pipe(Effect.flip), + ).toMatchObject({ _tag: "Session.ForkEmptyError", sessionID: parent.id }) + }), + ) + it.effect("forks before the selected boundary message", () => Effect.gen(function* () { const session = yield* Session.Service @@ -286,16 +297,27 @@ describe("Session.create", () => { tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } }, }) - const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id }) - const beforeFirst = yield* session.fork({ sessionID: parent.id, messageID: first.id }) - const complete = yield* session.fork({ sessionID: parent.id }) + const forked = yield* session.fork({ + sessionID: parent.id, + boundary: { type: "before", messageID: second.id }, + }) + const beforeFirst = yield* session.fork({ + sessionID: parent.id, + boundary: { type: "before", messageID: first.id }, + }) + const complete = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }) const context = yield* session.context(forked.id) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) - expect(forked.fork).toEqual({ sessionID: parent.id, messageID: second.id }) + expect(forked.fork).toEqual({ + sessionID: parent.id, + boundary: { type: "before", messageID: second.id }, + }) expect(context).toMatchObject([{ text: "First" }]) expect(context[0]?.id).not.toBe(first.id) - expect(history[0]).toMatchObject({ data: { from: second.id } }) + expect(history[0]).toMatchObject({ + data: { boundary: { type: "before", messageID: second.id } }, + }) expect(forked).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } }) expect(yield* session.context(beforeFirst.id)).toEqual([]) expect(beforeFirst).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 3fc569c2f9..c6b7d7b9c7 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1107,7 +1107,7 @@ describe("SessionRunnerLLM", () => { systemBaseline = "Latest context" yield* runPrompt(session, "Third") - const forked = yield* session.fork({ sessionID, messageID: second.id }) + const forked = yield* session.fork({ sessionID, boundary: { type: "before", messageID: second.id } }) expect( yield* (yield* Database.Service).db .select() @@ -1115,14 +1115,13 @@ describe("SessionRunnerLLM", () => { .where(eq(InstructionStateTable.session_id, forked.id)) .get(), ).toMatchObject({ - initial_values: { "test/context": Instructions.hash("Initial context") }, + initial_values: { "test/context": Instructions.hash("Changed context") }, current_values: { "test/context": Instructions.hash("Changed context") }, }) yield* session.prompt({ sessionID: forked.id, text: "Forked", resume: false }) yield* session.resume(forked.id) - expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"]) - expect(systemTexts(requests.at(-1)!)).toContain("Changed context") + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"]) expect(systemTexts(requests.at(-1)!)).toContain("Latest context") const { db } = yield* Database.Service @@ -1151,19 +1150,22 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("caps nested fork instruction ancestry at the selected message", () => + it.effect("keeps nested forks self-contained", () => Effect.gen(function* () { const session = yield* setup yield* runPrompt(session, "First") systemBaseline = "Changed context" const second = yield* runPrompt(session, "Second") - const child = yield* session.fork({ sessionID, messageID: second.id }) + const child = yield* session.fork({ sessionID, boundary: { type: "before", messageID: second.id } }) const inheritedFirst = (yield* session.messages({ sessionID: child.id })).find( (message) => message.type === "user" && message.text === "First", ) if (!inheritedFirst) return yield* Effect.die(new Error("Nested fork boundary message not found")) - const grandchild = yield* session.fork({ sessionID: child.id, messageID: inheritedFirst.id }) + const grandchild = yield* session.fork({ + sessionID: child.id, + boundary: { type: "before", messageID: inheritedFirst.id }, + }) expect( yield* (yield* Database.Service).db @@ -1172,8 +1174,8 @@ describe("SessionRunnerLLM", () => { .where(eq(InstructionStateTable.session_id, grandchild.id)) .get(), ).toMatchObject({ - initial_values: { "test/context": Instructions.hash("Initial context") }, - current_values: { "test/context": Instructions.hash("Initial context") }, + initial_values: { "test/context": Instructions.hash("Changed context") }, + current_values: { "test/context": Instructions.hash("Changed context") }, }) return undefined }), diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index b7cc24d709..011fa3d928 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -1145,7 +1145,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1181,7 +1188,7 @@ } } }, - "description": "Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.", + "description": "Create a child session by copying projected history through or before a message boundary.", "summary": "Fork session", "requestBody": { "content": { @@ -1189,22 +1196,13 @@ "schema": { "type": "object", "properties": { - "messageID": { - "anyOf": [ - { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - { - "type": "null" - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkRequestBoundary" } }, + "required": [ + "boundary" + ], "additionalProperties": false } } @@ -12223,6 +12221,58 @@ ], "additionalProperties": false }, + "Session.ForkBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + } + ] + }, "Money.USD": { "type": "number" }, @@ -12385,17 +12435,13 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" } }, "required": [ - "sessionID" + "sessionID", + "boundary" ], "additionalProperties": false }, @@ -12595,6 +12641,49 @@ ], "additionalProperties": false }, + "Session.ForkRequestBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, "MessageNotFoundError": { "type": "object", "properties": { @@ -12783,14 +12872,6 @@ "SessionPending.User": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -12828,7 +12909,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -12957,14 +13037,6 @@ "SessionPending.Synthetic": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -13002,7 +13074,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -13015,14 +13086,6 @@ "SessionPending.Compaction": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -13050,7 +13113,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -13705,7 +13767,14 @@ "type": "string" }, "name": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -13715,7 +13784,7 @@ ], "additionalProperties": false }, - "LLM.ToolContent": { + "Tool.Content": { "anyOf": [ { "$ref": "#/components/schemas/Tool.TextContent" @@ -13741,12 +13810,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -13795,12 +13864,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -14829,27 +14898,27 @@ } ] }, - "parentSeq": { - "type": "integer", - "allOf": [ - { - "minimum": -1 - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" }, - "from": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" + "instructions": { + "type": "object", + "patternProperties": { + "^[a-z0-9][a-z0-9._-]*\\/[a-z0-9][a-z0-9._/-]*$": { + "type": "string", + "allOf": [ + { + "pattern": "^[a-f0-9]{64}$" + } + ] } - ] + } } }, "required": [ "sessionID", "parentID", - "parentSeq" + "boundary" ], "additionalProperties": false } @@ -17114,6 +17183,49 @@ ], "additionalProperties": false }, + "Tool.FileContent1": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "uri", + "mime" + ], + "additionalProperties": false + }, + "Tool.Content1": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent1" + } + ] + }, "Session.Message.ProviderState8": { "type": "object" }, @@ -17197,12 +17309,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { @@ -17320,12 +17432,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 3e9a4a5015..be963b8a69 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -207,17 +207,16 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.post("session.fork", "/api/session/:sessionID/fork", { params: { sessionID: Session.ID }, - payload: Schema.Struct({ messageID: SessionMessage.ID.pipe(Schema.optional) }), + payload: Schema.Struct({ boundary: Session.ForkRequestBoundary }), success: Schema.Struct({ data: Session.Info }), - error: [SessionNotFoundError, MessageNotFoundError], + error: [SessionNotFoundError, MessageNotFoundError, InvalidRequestError], }) .middleware(sessionLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.fork", summary: "Fork session", - description: - "Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.", + description: "Create a child session by copying projected history through or before a message boundary.", }), ), ) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 6a9258a549..4a00ae4b4b 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -22,6 +22,7 @@ import { Snapshot } from "./snapshot.js" import { TokenUsage } from "./token-usage.js" import { SessionPending } from "./session-pending.js" import { Project } from "./project.js" +import { SessionFork } from "./session-fork.js" export { FileAttachment } @@ -127,8 +128,8 @@ export const Forked = Event.durable({ schema: { ...Base, parentID: SessionID, - parentSeq: Schema.Int.check(Schema.isGreaterThanOrEqualTo(-1)), - from: SessionMessage.ID.pipe(optional), + boundary: SessionFork.Boundary, + instructions: Instruction.Values.pipe(optional), }, }) export type Forked = typeof Forked.Type diff --git a/packages/schema/src/session-fork.ts b/packages/schema/src/session-fork.ts new file mode 100644 index 0000000000..d00df38c17 --- /dev/null +++ b/packages/schema/src/session-fork.ts @@ -0,0 +1,16 @@ +export * as SessionFork from "./session-fork.js" + +import { Schema } from "effect" +import { SessionMessage } from "./session-message.js" + +export const Boundary = Schema.Union([ + Schema.Struct({ type: Schema.Literal("before"), messageID: SessionMessage.ID }), + Schema.Struct({ type: Schema.Literal("through"), messageID: SessionMessage.ID }), +]).annotate({ identifier: "Session.ForkBoundary" }) +export type Boundary = typeof Boundary.Type + +export const RequestBoundary = Schema.Union([ + Schema.Struct({ type: Schema.Literal("before"), messageID: SessionMessage.ID }), + Schema.Struct({ type: Schema.Literal("through") }), +]).annotate({ identifier: "Session.ForkRequestBoundary" }) +export type RequestBoundary = typeof RequestBoundary.Type diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts index fffb4b7b3d..e7e9525b24 100644 --- a/packages/schema/src/session.ts +++ b/packages/schema/src/session.ts @@ -8,10 +8,10 @@ import { Project } from "./project.js" import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema.js" import { SessionEvent } from "./session-event.js" import { SessionID } from "./session-id.js" -import { SessionMessage } from "./session-message.js" import { Money } from "./money.js" import { TokenUsage } from "./token-usage.js" import { Revert } from "./session-revert.js" +import { SessionFork } from "./session-fork.js" export const ID = SessionID export type ID = SessionID @@ -19,6 +19,10 @@ export type ID = SessionID export const Event = SessionEvent export { Revert } +export const ForkBoundary = SessionFork.Boundary +export type ForkBoundary = SessionFork.Boundary +export const ForkRequestBoundary = SessionFork.RequestBoundary +export type ForkRequestBoundary = SessionFork.RequestBoundary export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ @@ -26,8 +30,7 @@ export const Info = Schema.Struct({ parentID: ID.pipe(optional), fork: Schema.Struct({ sessionID: ID, - /** Messages before this exclusive boundary are copied into the fork. */ - messageID: SessionMessage.ID.pipe(optional), + boundary: ForkBoundary, }).pipe(optional), projectID: Project.ID, agent: Agent.ID.pipe(optional), diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 796116da01..1f022483c3 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -131,7 +131,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl "session.fork", Effect.fn(function* (ctx) { return { - data: yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }).pipe( + data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe( Effect.catchTag( "Session.NotFoundError", (error) => @@ -149,6 +149,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl message: `Message not found: ${error.messageID}`, }), ), + Effect.catchTag( + "Session.ForkEmptyError", + (error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }), + ), ), } }), diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 19dc5cedec..a0f062e13b 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -583,7 +583,7 @@ function App(props: { pair?: DialogPairCredentials }) { return } void client.api.session - .fork({ sessionID: match }) + .fork({ sessionID: match, boundary: { type: "through" } }) .then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt })) .catch(toast.error) }) @@ -596,7 +596,7 @@ function App(props: { pair?: DialogPairCredentials }) { if (forked || !args.sessionID || !args.fork) return forked = true void client.api.session - .fork({ sessionID: args.sessionID }) + .fork({ sessionID: args.sessionID, boundary: { type: "through" } }) .then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt })) .catch(toast.error) }) diff --git a/packages/tui/src/routes/session/dialog-fork.tsx b/packages/tui/src/routes/session/dialog-fork.tsx index 4a8ac9ed8f..b99584d984 100644 --- a/packages/tui/src/routes/session/dialog-fork.tsx +++ b/packages/tui/src/routes/session/dialog-fork.tsx @@ -20,24 +20,28 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov const fork = async (messageID?: string) => { setPending(true) - const result = await client.api.session.fork({ sessionID: props.sessionID, messageID }).catch((error) => { - toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) - return undefined - }) + const result = await client.api.session + .fork({ + sessionID: props.sessionID, + boundary: messageID ? { type: "before", messageID } : { type: "through" }, + }) + .catch((error) => { + toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) + return undefined + }) if (!result) return dialog.clear() const message = messageID ? data.session.message.get(props.sessionID, messageID) : undefined const prompt = message?.type === "user" ? projectedPromptInput(message) : undefined route.navigate({ sessionID: result.id, type: "session", - prompt: - prompt - ? { - ...prompt, - agents: prompt.agents ?? [], - pasted: [], - } - : undefined, + prompt: prompt + ? { + ...prompt, + agents: prompt.agents ?? [], + pasted: [], + } + : undefined, }) dialog.clear() toast.show({ message: "Forked session", variant: "success", duration: 4000 }) @@ -75,11 +79,7 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov } > - props.onMove?.(option.value)} - title="Fork session" - options={options()} - /> + props.onMove?.(option.value)} title="Fork session" options={options()} /> ) } diff --git a/packages/www/openapi.json b/packages/www/openapi.json index b7cc24d709..011fa3d928 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -1145,7 +1145,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1181,7 +1188,7 @@ } } }, - "description": "Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.", + "description": "Create a child session by copying projected history through or before a message boundary.", "summary": "Fork session", "requestBody": { "content": { @@ -1189,22 +1196,13 @@ "schema": { "type": "object", "properties": { - "messageID": { - "anyOf": [ - { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - { - "type": "null" - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkRequestBoundary" } }, + "required": [ + "boundary" + ], "additionalProperties": false } } @@ -12223,6 +12221,58 @@ ], "additionalProperties": false }, + "Session.ForkBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + } + ] + }, "Money.USD": { "type": "number" }, @@ -12385,17 +12435,13 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" } }, "required": [ - "sessionID" + "sessionID", + "boundary" ], "additionalProperties": false }, @@ -12595,6 +12641,49 @@ ], "additionalProperties": false }, + "Session.ForkRequestBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, "MessageNotFoundError": { "type": "object", "properties": { @@ -12783,14 +12872,6 @@ "SessionPending.User": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -12828,7 +12909,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -12957,14 +13037,6 @@ "SessionPending.Synthetic": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -13002,7 +13074,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -13015,14 +13086,6 @@ "SessionPending.Compaction": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -13050,7 +13113,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -13705,7 +13767,14 @@ "type": "string" }, "name": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -13715,7 +13784,7 @@ ], "additionalProperties": false }, - "LLM.ToolContent": { + "Tool.Content": { "anyOf": [ { "$ref": "#/components/schemas/Tool.TextContent" @@ -13741,12 +13810,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -13795,12 +13864,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -14829,27 +14898,27 @@ } ] }, - "parentSeq": { - "type": "integer", - "allOf": [ - { - "minimum": -1 - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" }, - "from": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" + "instructions": { + "type": "object", + "patternProperties": { + "^[a-z0-9][a-z0-9._-]*\\/[a-z0-9][a-z0-9._/-]*$": { + "type": "string", + "allOf": [ + { + "pattern": "^[a-f0-9]{64}$" + } + ] } - ] + } } }, "required": [ "sessionID", "parentID", - "parentSeq" + "boundary" ], "additionalProperties": false } @@ -17114,6 +17183,49 @@ ], "additionalProperties": false }, + "Tool.FileContent1": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "uri", + "mime" + ], + "additionalProperties": false + }, + "Tool.Content1": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent1" + } + ] + }, "Session.Message.ProviderState8": { "type": "object" }, @@ -17197,12 +17309,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { @@ -17320,12 +17432,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index b7cc24d709..011fa3d928 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -1145,7 +1145,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1181,7 +1188,7 @@ } } }, - "description": "Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.", + "description": "Create a child session by copying projected history through or before a message boundary.", "summary": "Fork session", "requestBody": { "content": { @@ -1189,22 +1196,13 @@ "schema": { "type": "object", "properties": { - "messageID": { - "anyOf": [ - { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - { - "type": "null" - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkRequestBoundary" } }, + "required": [ + "boundary" + ], "additionalProperties": false } } @@ -12223,6 +12221,58 @@ ], "additionalProperties": false }, + "Session.ForkBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + } + ] + }, "Money.USD": { "type": "number" }, @@ -12385,17 +12435,13 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" } }, "required": [ - "sessionID" + "sessionID", + "boundary" ], "additionalProperties": false }, @@ -12595,6 +12641,49 @@ ], "additionalProperties": false }, + "Session.ForkRequestBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, "MessageNotFoundError": { "type": "object", "properties": { @@ -12783,14 +12872,6 @@ "SessionPending.User": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -12828,7 +12909,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -12957,14 +13037,6 @@ "SessionPending.Synthetic": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -13002,7 +13074,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -13015,14 +13086,6 @@ "SessionPending.Compaction": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -13050,7 +13113,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -13705,7 +13767,14 @@ "type": "string" }, "name": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -13715,7 +13784,7 @@ ], "additionalProperties": false }, - "LLM.ToolContent": { + "Tool.Content": { "anyOf": [ { "$ref": "#/components/schemas/Tool.TextContent" @@ -13741,12 +13810,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -13795,12 +13864,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -14829,27 +14898,27 @@ } ] }, - "parentSeq": { - "type": "integer", - "allOf": [ - { - "minimum": -1 - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" }, - "from": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" + "instructions": { + "type": "object", + "patternProperties": { + "^[a-z0-9][a-z0-9._-]*\\/[a-z0-9][a-z0-9._/-]*$": { + "type": "string", + "allOf": [ + { + "pattern": "^[a-f0-9]{64}$" + } + ] } - ] + } } }, "required": [ "sessionID", "parentID", - "parentSeq" + "boundary" ], "additionalProperties": false } @@ -17114,6 +17183,49 @@ ], "additionalProperties": false }, + "Tool.FileContent1": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "uri", + "mime" + ], + "additionalProperties": false + }, + "Tool.Content1": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent1" + } + ] + }, "Session.Message.ProviderState8": { "type": "object" }, @@ -17197,12 +17309,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { @@ -17320,12 +17432,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { From bd906d468d03f0118703e53089e3d87bdb0ee888 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 09:49:38 -0400 Subject: [PATCH 21/51] refactor(core): make watcher subscription effectful --- packages/core/src/config.ts | 3 +- .../core/src/filesystem/location-watcher.ts | 18 ++++----- packages/core/src/filesystem/watcher.ts | 22 +++++++---- packages/core/src/plugin/supervisor.ts | 3 +- packages/core/test/filesystem/watcher.test.ts | 37 +++++++++++-------- 5 files changed, 49 insertions(+), 34 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 7ded4cc66c..05ba4b0db0 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -393,7 +393,8 @@ export const layer = (options?: Options) => Layer.effect( const key = JSON.stringify(target) if (watched.has(key)) continue watched.add(key) - yield* watcher.subscribe(target).pipe( + const stream = yield* watcher.subscribe(target) + yield* stream.pipe( Stream.runForEach((update) => PubSub.publish(updates, update)), Effect.forkScoped({ startImmediately: true }), ) diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts index 25c9832059..934e99f3bf 100644 --- a/packages/core/src/filesystem/location-watcher.ts +++ b/packages/core/src/filesystem/location-watcher.ts @@ -47,13 +47,12 @@ const layer = Layer.effect( const home = path.resolve(location.directory) === path.resolve(os.homedir()) if (!home && location.vcs) { - yield* watcher - .subscribe({ - path: location.directory, - type: "directory", - ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)], - }) - .pipe(Stream.runForEach(publish), Effect.forkScoped) + const updates = yield* watcher.subscribe({ + path: location.directory, + type: "directory", + ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)], + }) + yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped) } if (home) { yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory }) @@ -68,9 +67,8 @@ const layer = Layer.effect( const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( (entry) => (entry.name === "HEAD" ? [] : [entry.name]), ) - yield* watcher - .subscribe({ path: vcs, type: "directory", ignore }) - .pipe(Stream.runForEach(publish), Effect.forkScoped) + const updates = yield* watcher.subscribe({ path: vcs, type: "directory", ignore }) + yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped) } } }).pipe( diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index f7fef53029..7ff3aa5e5c 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -59,7 +59,7 @@ export interface NativeInterface { export class Native extends Context.Service()("@opencode/Watcher/Native") {} export interface Interface { - readonly subscribe: (input: WatchInput) => Stream.Stream + readonly subscribe: (input: WatchInput) => Effect.Effect> } export const Options = Schema.Struct({ @@ -83,7 +83,7 @@ export const layer = (options?: Options) => Service, Effect.gen(function* () { if (options?.enabled === false) { - return Service.of({ subscribe: () => Stream.empty }) + return Service.of({ subscribe: () => Effect.succeed(Stream.empty) }) } const native = yield* Native @@ -131,11 +131,19 @@ export const layer = (options?: Options) => const subscribe = (input: WatchInput) => { const target = path.resolve(input.path) const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted() - return Stream.unwrap( - RcMap.get(watchers, { type: input.type, target, ignore }).pipe( - Effect.map((pubsub) => Stream.fromPubSub(pubsub)), - ), - ) + return Effect.gen(function* () { + yield* Effect.logInfo("watcher subscribe", { + path: target, + type: input.type, + ignores: ignore.length, + }) + return Stream.unwrap( + Effect.gen(function* () { + const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore }) + return Stream.fromPubSub(pubsub) + }), + ) + }) } return Service.of({ subscribe }) diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 39bb868678..bcca1f53fa 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -253,7 +253,8 @@ const layer = Layer.effect( // inside), so don't watch what can't trigger anything. if (yield* fs.isDir(operation.target)) continue watched.add(operation.target) - yield* watcher.subscribe({ path: operation.target, type: "file" }).pipe( + const updates = yield* watcher.subscribe({ path: operation.target, type: "file" }) + yield* updates.pipe( Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)), Effect.catchCause((cause) => Effect.logError("configured plugin watch failed", { target: operation.target, cause }), diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 4ff39443e1..22dddaf017 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -30,9 +30,12 @@ describe("Watcher.testLayer", () => { Effect.gen(function* () { const watcher = yield* Watcher.Service const test = yield* Watcher.Test - const received = yield* watcher - .subscribe({ path: "/root", type: "directory" }) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) + const updates = yield* watcher.subscribe({ path: "/root", type: "directory" }) + const received = yield* updates.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ) yield* Effect.yieldNow yield* test.emit({ type: "update", path: "/root/file.md" }) @@ -72,9 +75,10 @@ describe("Watcher lifecycle", () => { const interrupted = yield* Deferred.make() yield* Effect.gen(function* () { const watcher = yield* Watcher.Service - const consumer = yield* watcher - .subscribe({ path: "/pending", type: "directory" }) - .pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true })) + const consumer = yield* watcher.subscribe({ path: "/pending", type: "directory" }).pipe( + Effect.flatMap(Stream.runDrain), + Effect.forkScoped({ startImmediately: true }), + ) yield* Deferred.await(started) yield* Fiber.interrupt(consumer) expect(yield* Deferred.isDone(interrupted)).toBe(true) @@ -95,9 +99,10 @@ describe("Watcher lifecycle", () => { return Effect.gen(function* () { const watcher = yield* Watcher.Service const consume = () => - watcher - .subscribe({ path: "/shared", type: "directory" }) - .pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true })) + watcher.subscribe({ path: "/shared", type: "directory" }).pipe( + Effect.flatMap(Stream.runDrain), + Effect.forkScoped({ startImmediately: true }), + ) const first = yield* consume() const second = yield* consume() yield* Effect.yieldNow @@ -117,9 +122,8 @@ describe("Watcher lifecycle", () => { return Effect.gen(function* () { const consumer = yield* Effect.gen(function* () { const watcher = yield* Watcher.Service - const consumer = yield* watcher - .subscribe({ path: "/active", type: "directory" }) - .pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true })) + const updates = yield* watcher.subscribe({ path: "/active", type: "directory" }) + const consumer = yield* updates.pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true })) yield* Effect.yieldNow expect(counts.subscribes).toBe(1) expect(counts.unsubscribes).toBe(0) @@ -250,9 +254,12 @@ describeWatcher("LocationWatcher", () => { const watcher = yield* Watcher.Service const target = path.join(directory, "opencode.json") const sibling = path.join(directory, "other.json") - const update = yield* watcher - .subscribe({ path: target, type: "file" }) - .pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true })) + const updates = yield* watcher.subscribe({ path: target, type: "file" }) + const update = yield* updates.pipe( + Stream.take(1), + Stream.runHead, + Effect.forkScoped({ startImmediately: true }), + ) yield* fs.writeFileString(sibling, "sibling") const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe( Effect.repeat(Schedule.spaced("10 millis")), From 247f14f9556c31ee532cb4a79a83283e753adc62 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 10:16:34 -0400 Subject: [PATCH 22/51] feat(tui): add replaceable prompt footer slot --- .opencode/plugins/tui/discovery-smoke.ts | 17 ++- packages/plugin/src/tui/context.ts | 4 + packages/tui/src/component/prompt/index.tsx | 102 ++---------------- .../tui/src/feature-plugins/prompt/footer.tsx | 89 +++++++++++++++ packages/tui/src/plugin/builtins.ts | 2 + .../feature-plugins/prompt-footer.test.tsx | 44 ++++++++ 6 files changed, 152 insertions(+), 106 deletions(-) create mode 100644 packages/tui/src/feature-plugins/prompt/footer.tsx create mode 100644 packages/tui/test/feature-plugins/prompt-footer.test.tsx diff --git a/.opencode/plugins/tui/discovery-smoke.ts b/.opencode/plugins/tui/discovery-smoke.ts index f0144918f1..fb7edaf349 100644 --- a/.opencode/plugins/tui/discovery-smoke.ts +++ b/.opencode/plugins/tui/discovery-smoke.ts @@ -2,15 +2,12 @@ import type { Context } from "../../../packages/plugin/src/tui/context" export default { id: "test.tui-discovery-smoke", - setup(context: Context) { - const timer = setTimeout(() => { - context.ui.toast.show({ - title: "TUI plugin discovery works", - message: "Loaded .opencode/plugins/tui/discovery-smoke.ts", - variant: "success", - duration: 30_000, - }) - }, 1_000) - return () => clearTimeout(timer) + setup(_context: Context) { + // context.ui.toast.show({ + // title: "TUI plugin discovery works", + // message: "Loaded .opencode/plugins/tui/discovery-smoke.ts", + // variant: "success", + // duration: 30_000, + // }) }, } diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 9d092f7245..e4087b5aa0 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -116,6 +116,10 @@ export interface Page { export interface SlotMap { readonly app: Readonly> readonly "home.footer": Readonly> + readonly "prompt.footer.end": { + readonly sessionID?: string + readonly mode: "normal" | "shell" + } readonly "sidebar.content": { readonly sessionID: string } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 44e98f92d5..732359a4a7 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -52,8 +52,8 @@ import { readLocalAttachment } from "./local-attachment" import { useData } from "../../context/data" import { useLocation } from "../../context/location" import { Keymap, type KeymapCommand } from "../../context/keymap" -import { contextUsage, formatContextUsage } from "../../util/session" import { abbreviateHome } from "../../runtime" +import { PluginSlot } from "../../plugin/context" registerOpencodeSpinner() @@ -93,11 +93,6 @@ export type PromptRef = { submit(): void } -const money = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", -}) - const DRAFT_RETENTION_MIN_CHARS = 20 function randomIndex(count: number) { @@ -170,22 +165,9 @@ export function Prompt(props: PromptProps) { const dialog = useDialog() const toast = useToast() const status = createMemo(() => data.session.status(props.sessionID ?? "")) - const activeSubagents = createMemo(() => { - if (!props.sessionID) return 0 - return data.session - .family(props.sessionID) - .filter((id) => id !== props.sessionID && data.session.status(id) === "running").length - }) - const runningShells = createMemo( - () => - data.shell.list(currentLocation.current).filter((shell) => shell.metadata.sessionID === props.sessionID).length, - ) const history = usePromptHistory() const stash = usePromptStash() const keymap = Keymap.use() - const agentShortcut = Keymap.useShortcut("agent.cycle") - const paletteShortcut = Keymap.useShortcut("command.palette.show") - const liveWorkShortcut = Keymap.useShortcut("session.child.first") const renderer = useRenderer() const exit = useExit() const dimensions = useTerminalDimensions() @@ -301,42 +283,6 @@ export function Prompt(props: PromptProps) { if (!props.disabled) input.cursorColor = theme.text.default }) - const usage = createMemo(() => { - if (!props.sessionID) return - const session = data.session.get(props.sessionID) - if (!session) return - const cost = data.session.cost(props.sessionID) - const formattedCost = cost > 0 ? money.format(cost) : undefined - const context = contextUsage( - data.session.message.list(props.sessionID), - data.location.model.list(session.location), - session.revert?.messageID, - ) - return { - context: context ? formatContextUsage(context.tokens, context.percent) : undefined, - cost: formattedCost, - } - }) - - const subagentStatusLabel = createMemo(() => { - const agents = activeSubagents() - if (!agents) return undefined - return `${agents} subagent${agents === 1 ? "" : "s"}` - }) - const shellStatusLabel = createMemo(() => { - const shells = runningShells() - if (!shells) return undefined - return `${shells} shell${shells === 1 ? "" : "s"}` - }) - const liveWorkStatusVisible = createMemo(() => Boolean(subagentStatusLabel() || shellStatusLabel())) - - // Far-right footer cluster: live work counts lead, then context/cost usage. - // When empty, the cluster falls back to the hotkey hints. - const statusItems = createMemo(() => { - const stats = usage() - return [stats?.context, stats?.cost].filter(Boolean) - }) - const [store, setStore] = createStore<{ prompt: PromptInfo mode: "normal" | "shell" @@ -1603,47 +1549,11 @@ export function Prompt(props: PromptProps) { )} - - - - 0}> - - - {(shortcut) => {shortcut()} } - - - {(label) => {label()}} - - - · - - - {(label) => {label()}} - - 0}> - · - - 0}> - {statusItems().join(" · ")} - - - - - - {agentShortcut()} agents - - - - - {paletteShortcut()} commands - - - - - esc exit shell mode - - - + { + if (!props.sessionID) return 0 + return props.context.data.session + .family(props.sessionID) + .filter((id) => id !== props.sessionID && props.context.data.session.status(id) === "running").length + }) + const runningShells = createMemo(() => { + if (!props.sessionID) return 0 + return props.context.data.shell + .list(props.context.location) + .filter((shell) => shell.metadata.sessionID === props.sessionID).length + }) + const subagents = createMemo(() => { + const count = activeSubagents() + return count ? `${count} subagent${count === 1 ? "" : "s"}` : undefined + }) + const shells = createMemo(() => { + const count = runningShells() + return count ? `${count} shell${count === 1 ? "" : "s"}` : undefined + }) + const status = createMemo(() => { + if (!props.sessionID) return [] + const session = props.context.data.session.get(props.sessionID) + if (!session) return [] + const usage = contextUsage( + props.context.data.session.message.list(props.sessionID), + props.context.data.location.model.list(session.location), + session.revert?.messageID, + ) + const cost = props.context.data.session.cost(props.sessionID) + return [usage ? formatContextUsage(usage.tokens, usage.percent) : undefined, cost > 0 ? money.format(cost) : undefined] + .filter((item): item is string => Boolean(item)) + }) + const live = createMemo(() => Boolean(subagents() || shells())) + const shortcut = (id: string) => props.context.keymap.shortcuts(id)[0] + + return ( + + + + 0}> + + + {(value) => {value()} } + + {(value) => {value()}} + · + {(value) => {value()}} + 0}> · + 0}>{status().join(" · ")} + + + + + {shortcut("agent.cycle")} agents + + + + + {shortcut("command.palette.show")} commands + + + + + esc exit shell mode + + + + ) +} + +export default Plugin.define({ + id: "opencode.prompt-footer", + setup(context) { + context.ui.slot("prompt.footer.end", (props) => ( + + )) + }, +}) diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index 79765b0658..0c18cdafa6 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -1,4 +1,5 @@ import HomeFooter from "../feature-plugins/home/footer" +import PromptFooter from "../feature-plugins/prompt/footer" import SidebarContext from "../feature-plugins/sidebar/context" import SidebarFooter from "../feature-plugins/sidebar/footer" import SidebarLsp from "../feature-plugins/sidebar/lsp" @@ -10,6 +11,7 @@ import Scrap from "../feature-plugins/system/scrap" export const builtins = [ HomeFooter, + PromptFooter, SidebarContext, SidebarMcp, SidebarLsp, diff --git a/packages/tui/test/feature-plugins/prompt-footer.test.tsx b/packages/tui/test/feature-plugins/prompt-footer.test.tsx new file mode 100644 index 0000000000..313f7b8d27 --- /dev/null +++ b/packages/tui/test/feature-plugins/prompt-footer.test.tsx @@ -0,0 +1,44 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { RGBA } from "@opentui/core" +import { testRender } from "@opentui/solid" +import type { Context } from "@opencode-ai/plugin/tui/context" +import { PromptFooter } from "../../src/feature-plugins/prompt/footer" + +test("prompt footer separates simultaneous subagent, shell, and usage status", async () => { + const color = RGBA.fromInts(200, 200, 200) + const context = { + location: { directory: "/workspace" }, + theme: { text: { default: color, subdued: color } }, + keymap: { + shortcuts: (id: string) => (id === "session.child.first" ? ["ctrl+j"] : id === "command.palette.show" ? ["ctrl+p"] : []), + }, + data: { + session: { + family: () => ["session", "child"], + status: (id: string) => (id === "child" ? "running" : "idle"), + get: () => ({ id: "session", location: { directory: "/workspace" } }), + cost: () => 1, + message: { list: () => [] }, + }, + shell: { + list: () => [{ metadata: { sessionID: "session" } }], + }, + location: { + model: { list: () => [] }, + }, + }, + } as unknown as Context + const app = await testRender(() => , { + width: 80, + height: 2, + }) + + try { + await app.renderOnce() + expect(app.captureCharFrame()).toContain("ctrl+j 1 subagent · 1 shell · $1.00") + expect(app.captureCharFrame()).toContain("ctrl+p commands") + } finally { + app.renderer.destroy() + } +}) From 813c41ff6c56c290ce4cddd548d977a0549e1aed Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:16:45 -0500 Subject: [PATCH 23/51] fix(core): simplify shell execution boundary (#39530) --- packages/core/src/shell/select.ts | 2 +- packages/core/src/tool/plugin/shell.ts | 38 +++----------------------- packages/core/test/shell.test.ts | 7 +++++ packages/core/test/tool-shell.test.ts | 27 ------------------ 4 files changed, 12 insertions(+), 62 deletions(-) diff --git a/packages/core/src/shell/select.ts b/packages/core/src/shell/select.ts index 8086b23048..39e4af1b87 100644 --- a/packages/core/src/shell/select.ts +++ b/packages/core/src/shell/select.ts @@ -173,7 +173,7 @@ export function args(file: string, command: string) { if (n === "nu" || n === "fish") return ["-c", command] if (n === "zsh" || n === "bash") return ["-c", command] if (n === "cmd") return ["/c", command] - if (ps(file)) return ["-NoProfile", "-Command", command] + if (ps(file)) return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command] return ["-c", command] } diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 384f60e461..84723863ac 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -1,6 +1,5 @@ export * as ShellTool from "./shell" -import path from "path" import { ToolFailure } from "@opencode-ai/ai" import type { Content } from "@opencode-ai/schema/tool" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" @@ -66,18 +65,14 @@ const Output = Schema.Struct({ ...StructuredOutput.fields, output: Schema.String, status: Schema.optionalKey(Schema.Literals(["completed", "running"])), - warnings: Schema.optionalKey(Schema.Array(Schema.String)), }) type Output = typeof Output.Type const modelOutput = (output: Output): string | undefined => { - const warnings = output.warnings?.length - ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` - : "" - if (output.status === "running") return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}` - if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.` - return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.` + if (output.status === "running") return BACKGROUND_INSTRUCTION + if (output.timeout) return "Command timed out before completion." + return `Command exited with code ${output.exit}.` } /** @@ -86,31 +81,12 @@ const modelOutput = (output: Output): string | undefined => { */ // TODO: Port tree-sitter bash / PowerShell parser-based approval reduction. // TODO: Port BashArity reusable command-prefix approvals. -// TODO: Replace token-based command-argument external-directory advisories with parser-based detection. // TODO: Add plugin shell.env environment augmentation once plugin hooks exist. // TODO: Persist job status and define restart recovery before exposing remote observation. // TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined. // TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it. // TODO: Revisit binary output handling if stdout/stderr decoding is text-only. -const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] -const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2") -const externalCommandDirectories = Effect.fn("ShellTool.externalCommandDirectories")(function* ( - fs: FSUtil.Interface, - command: string, - cwd: string, -) { - const directories = new Set() - for (const token of shellTokens(command)) { - const value = unquote(token).replace(/[;,|&]+$/, "") - if (!path.isAbsolute(value)) continue - const resolved = yield* fs.resolve(value) - if (FSUtil.contains(cwd, resolved)) continue - directories.add(yield* fs.resolve(path.dirname(resolved))) - } - return [...directories] -}) - export const Plugin = { id: "opencode.tool.shell", effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) { @@ -179,10 +155,6 @@ export const Plugin = { agent: context.agent, source, }) - const warnings = (yield* externalCommandDirectories(fsUtil, input.command, target.canonical)).map( - (directory) => - `Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`, - ) yield* permission.assert({ action: name, resources: [input.command], @@ -264,7 +236,6 @@ export const Plugin = { shellID: info.id, truncated: false, status: "running" as const, - ...(warnings.length ? { warnings } : {}), } } @@ -279,14 +250,13 @@ export const Plugin = { shellID: info.id, truncated: false, status: "running" as const, - ...(warnings.length ? { warnings } : {}), } } if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed")) if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled")) - return { ...(yield* Deferred.await(settled)), ...(warnings.length ? { warnings } : {}) } + return yield* Deferred.await(settled) }).pipe( Effect.map((output) => { const content: Array = [{ type: "text", text: output.output }] diff --git a/packages/core/test/shell.test.ts b/packages/core/test/shell.test.ts index 7878244225..8725e615fa 100644 --- a/packages/core/test/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -59,6 +59,13 @@ describe("shell", () => { expect(ShellSelect.args("/usr/bin/fish", "echo hi")).toEqual(["-c", "echo hi"]) expect(ShellSelect.args("/bin/zsh", "echo hi")).toEqual(["-c", "echo hi"]) expect(ShellSelect.args("/bin/bash", "echo hi")).toEqual(["-c", "echo hi"]) + expect(ShellSelect.args("pwsh", "Write-Output hi")).toEqual([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "Write-Output hi", + ]) }) if (process.platform === "win32") { diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index df53ae9403..2b540d7538 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -346,33 +346,6 @@ describe("ShellTool", () => { ), ) - it.live("reports external command arguments as advisory warnings without enforcing approval", () => - Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([active, outside]) => { - reset() - denyAction = "external_directory" - const target = path.join(outside.path, "secret.txt") - return withSession(active.path, (registry) => executeTool(registry, call({ command: `cat ${target}` }))).pipe( - Effect.andThen((settled) => - Effect.sync(() => { - expect(assertions.map((item) => item.action)).toEqual(["shell"]) - expect(settled.metadata).not.toHaveProperty("warnings") - expect(settled.content?.[1]).toMatchObject({ - type: "text", - text: expect.stringContaining("Warnings:"), - }) - }), - ), - ) - }, - ([active, outside]) => - Effect.promise(() => - Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), - ), - ), - ) - it.live("keeps non-zero exits useful", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), From 9ee337469d080cfb3ec615629016e9f4188177a0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 12:32:24 -0400 Subject: [PATCH 24/51] feat(tui): add persistent storage context --- packages/plugin/src/tui/context.ts | 11 ++ packages/tui/package.json | 1 + packages/tui/src/app.tsx | 185 ++++++++++---------- packages/tui/src/config/index.tsx | 3 + packages/tui/src/context/session-tabs.tsx | 204 ++++++++-------------- packages/tui/src/context/storage.tsx | 94 ++++++++++ packages/tui/src/plugin/context.tsx | 5 + 7 files changed, 280 insertions(+), 223 deletions(-) create mode 100644 packages/tui/src/context/storage.tsx diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index e4087b5aa0..05e66ba163 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -21,6 +21,16 @@ import type { } from "@opencode-ai/client" import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core" import type { JSX } from "@opentui/solid" +import type { Store } from "solid-js/store" + +export interface Storage { + store( + key: string, + options: { + readonly initial: Value + }, + ): readonly [Store, (mutation: (draft: Value) => void) => Promise] +} interface LocationCollection { list(location?: LocationRef): Value[] | undefined @@ -348,5 +358,6 @@ export interface Context { readonly attention: Attention readonly theme: any readonly keymap: Keymap + readonly storage: Storage readonly ui: UI } diff --git a/packages/tui/package.json b/packages/tui/package.json index ac929f9d6e..8ae0222ebe 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -20,6 +20,7 @@ "./context/exit": "./src/context/exit.tsx", "./context/log": "./src/context/log.tsx", "./context/runtime": "./src/context/runtime.tsx", + "./context/storage": "./src/context/storage.tsx", "./context/client": "./src/context/client.tsx", "./context/theme": "./src/context/theme.tsx", "./theme/discovery": "./src/theme/discovery.ts", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index a0f062e13b..d6ba165e42 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -90,6 +90,7 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" import { AttentionProvider } from "./context/attention" +import { StorageProvider } from "./context/storage" registerOpencodeSpinner() @@ -303,104 +304,106 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { worktree: global.data + "/worktree", }} > - finalizers.delete(finalizer) - }, - }} - > - + finalizers.delete(finalizer) + }, }} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 867576d352..faebe44f40 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -127,6 +127,9 @@ export const Info = Schema.Struct({ enabled: Schema.optional(Schema.Boolean).annotate({ description: "Use a persistent session tab strip instead of pinned quick-switch sessions", }), + scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({ + description: "Share session tabs globally or keep a separate set for each working directory", + }), }), ).annotate({ description: "Session tab settings" }), mini: Schema.optional( diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 14fc91a508..c59c00731d 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -1,15 +1,12 @@ -import { batch, createEffect, onCleanup, untrack } from "solid-js" -import { createStore, produce, reconcile } from "solid-js/store" -import path from "path" +import { createEffect, onCleanup } from "solid-js" import { isDeepEqual } from "remeda" import { createSimpleContext } from "./helper" import { useData } from "./data" import { useEvent } from "./event" import { useRoute } from "./route" -import { useTuiPaths } from "./runtime" import { useConfig } from "../config" -import { readJson, writeJsonAtomic } from "../util/persistence" -import { isRecord } from "../util/record" +import { useStorage } from "./storage" +import { useTuiPaths } from "./runtime" import { closeSessionTab, cycleSessionTab, @@ -21,11 +18,18 @@ import { type SessionTabUnread, } from "./session-tabs-model" -type PersistedState = { +type TabsState = { tabs: SessionTab[] unread: Record } +type PersistedState = { + global: TabsState + cwd: Record +} + +const empty = (): TabsState => ({ tabs: [], unread: {} }) + export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({ name: "SessionTabs", init: () => { @@ -33,21 +37,29 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp const data = useData() const event = useEvent() const config = useConfig().data - const filePath = path.join(useTuiPaths().state, "session-tabs.json") + const paths = useTuiPaths() const enabled = () => config.tabs?.enabled ?? false - const state: { - pending: boolean - saving: boolean - snapshot: string - value?: PersistedState - } = { pending: false, saving: false, snapshot: "" } - const [store, setStore] = createStore({ - ready: false, - tabs: [], - unread: {}, + const [store, updateStore] = useStorage().store("tabs", { + initial: { + global: empty(), + cwd: {}, + }, }) + const fallback = empty() let history: SessionTabHistory = { entries: [], index: -1 } + function state() { + if (config.tabs?.scope !== "cwd") return store.global + return store.cwd[paths.cwd] ?? fallback + } + + function update(mutation: (draft: TabsState) => void) { + const scope = config.tabs?.scope ?? "global" + void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch( + () => {}, + ) + } + const root = (sessionID: string) => data.session.root(sessionID) const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined) const status = (sessionID: string) => { @@ -55,7 +67,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp const members = data.session.family(session) const family = members.length > 0 ? members : [session] return { - unread: store.unread[session], + unread: state().unread[session], attention: family.some( (id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0, ), @@ -63,125 +75,54 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp } } - function save() { - if (!store.ready) { - state.pending = true - return - } - const value = { tabs: [...store.tabs], unread: { ...store.unread } } - const snapshot = JSON.stringify(value) - if (snapshot === state.snapshot && !state.saving) return - state.value = value - state.pending = true - flush() - } - - function flush() { - if (state.saving || !state.pending || !state.value) return - const value = state.value - const snapshot = JSON.stringify(value) - state.pending = false - if (snapshot === state.snapshot) return - state.saving = true - void writeJsonAtomic(filePath, value) - .then(() => { - state.snapshot = snapshot - }) - .catch(() => {}) - .finally(() => { - state.saving = false - flush() - }) - } - - function open(sessionID: string) { - const session = root(sessionID) - const next = openSessionTab(store.tabs, { sessionID: session, title: data.session.get(session)?.title }) - if (next === store.tabs) return { sessionID: session, changed: false } - setStore("tabs", reconcile(next)) - return { sessionID: session, changed: true } - } - - function clearUnread(sessionID: string) { - const session = root(sessionID) - if (!store.unread[session]) return false - setStore( - "unread", - produce((draft) => { - delete draft[session] - }), - ) - return true - } - function markUnread(sessionID: string, unread: SessionTabUnread) { if (!enabled()) return const session = root(sessionID) - if (current() === session || !store.tabs.some((tab) => tab.sessionID === session)) return - if (store.unread[session] === unread) return - setStore("unread", session, unread) - save() + if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return + if (state().unread[session] === unread) return + update((draft) => { + if (!draft.tabs.some((tab) => tab.sessionID === session)) return + draft.unread[session] = unread + }) } - readJson(filePath) - .then((value) => { - if (!isRecord(value)) return - const persisted = value - if (Array.isArray(persisted.tabs)) - setStore( - "tabs", - persisted.tabs.flatMap((tab) => { - if (!isRecord(tab) || typeof tab.sessionID !== "string") return [] - if ("title" in tab && tab.title !== undefined && typeof tab.title !== "string") return [] - return [{ sessionID: tab.sessionID, title: typeof tab.title === "string" ? tab.title : undefined }] - }), - ) - if (persisted.unread && typeof persisted.unread === "object") - setStore( - "unread", - Object.fromEntries( - Object.entries(persisted.unread).filter( - (entry): entry is [string, SessionTabUnread] => entry[1] === "activity" || entry[1] === "error", - ), - ), - ) - }) - .catch(() => {}) - .finally(() => { - setStore("ready", true) - if (state.pending) save() - else state.snapshot = JSON.stringify({ tabs: store.tabs, unread: store.unread }) - }) - createEffect(() => { if (!enabled()) return - if (!store.ready || route.data.type !== "session" || route.data.sessionID === "dummy") return - const routeSessionID = route.data.sessionID - batch(() => { - const opened = open(routeSessionID) - history = recordSessionTabHistory(history, opened.sessionID) - const changed = clearUnread(opened.sessionID) - if (opened.changed || changed) untrack(save) + if (route.data.type !== "session" || route.data.sessionID === "dummy") return + const sessionID = root(route.data.sessionID) + history = recordSessionTabHistory(history, sessionID) + const title = data.session.get(sessionID)?.title + const tabs = openSessionTab(state().tabs, { sessionID, title }) + if (tabs === state().tabs && !state().unread[sessionID]) return + update((draft) => { + draft.tabs = openSessionTab(draft.tabs, { sessionID, title }) + delete draft.unread[sessionID] }) }) createEffect(() => { - if (!enabled() || !store.ready) return - const next = store.tabs.reduce((tabs, tab) => { + if (!enabled()) return + const next = state().tabs.reduce((tabs, tab) => { const sessionID = root(tab.sessionID) return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title }) }, []) - const unread = Object.entries(store.unread).reduce>((result, entry) => { + const unread = Object.entries(state().unread).reduce>((result, entry) => { const sessionID = root(entry[0]) result[sessionID] = result[sessionID] === "error" ? "error" : entry[1] return result }, {}) - if (isDeepEqual(next, store.tabs) && isDeepEqual(unread, store.unread)) return - batch(() => { - setStore("tabs", reconcile(next)) - setStore("unread", reconcile(unread)) + if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return + update((draft) => { + draft.tabs = draft.tabs.reduce((tabs, tab) => { + const sessionID = root(tab.sessionID) + return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title }) + }, []) + draft.unread = Object.entries(draft.unread).reduce>((result, entry) => { + const sessionID = root(entry[0]) + result[sessionID] = result[sessionID] === "error" ? "error" : entry[1] + return result + }, {}) }) - save() }) onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity"))) @@ -200,26 +141,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp function remove(sessionID: string, navigate: boolean) { const target = root(sessionID) - const closed = closeSessionTab(store.tabs, target) - if (closed.tabs.length === store.tabs.length) return + const closed = closeSessionTab(state().tabs, target) + if (closed.tabs.length === state().tabs.length) return const selected = navigate && current() === target const previous = selected ? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1) : { history, sessionID: undefined } const next = previous.sessionID ?? closed.next history = previous.history - batch(() => { - setStore("tabs", reconcile(closed.tabs)) - clearUnread(target) - if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" }) + update((draft) => { + draft.tabs = closeSessionTab(draft.tabs, target).tabs + delete draft.unread[target] }) - save() + if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" }) } return { enabled, tabs() { - return store.tabs + return state().tabs }, current, status, @@ -231,7 +171,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp if (!enabled()) return const target = sessionID ? root(sessionID) : current() if (!target) { - const previous = store.tabs.at(-1) + const previous = state().tabs.at(-1) if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID }) return } @@ -239,13 +179,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }, cycle(direction: 1 | -1) { if (!enabled()) return - const tab = cycleSessionTab(store.tabs, current(), direction) + const tab = cycleSessionTab(state().tabs, current(), direction) if (tab) route.navigate({ type: "session", sessionID: tab.sessionID }) }, cycleUnread(direction: 1 | -1) { if (!enabled()) return const tab = cycleSessionTab( - store.tabs.filter((tab) => store.unread[tab.sessionID] || status(tab.sessionID).attention), + state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention), current(), direction, ) @@ -253,13 +193,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }, history(direction: 1 | -1) { if (!enabled()) return - const next = moveSessionTabHistory(history, store.tabs, current(), direction) + const next = moveSessionTabHistory(history, state().tabs, current(), direction) history = next.history if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID }) }, selectIndex(index: number) { if (!enabled()) return - const tab = store.tabs[index] + const tab = state().tabs[index] if (tab) route.navigate({ type: "session", sessionID: tab.sessionID }) }, } diff --git a/packages/tui/src/context/storage.tsx b/packages/tui/src/context/storage.tsx new file mode 100644 index 0000000000..0f1303c781 --- /dev/null +++ b/packages/tui/src/context/storage.tsx @@ -0,0 +1,94 @@ +import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js" +import { createStore, reconcile, type Store } from "solid-js/store" +import path from "path" +import { mkdirSync, readFileSync, watch } from "fs" +import { Flock } from "@opencode-ai/util/flock" +import { writeJsonAtomic } from "../util/persistence" +import { useTuiApp, useTuiPaths } from "./runtime" + +type Options = { + readonly initial: Value +} + +type Entry = readonly [Store, (mutation: (draft: Value) => void) => Promise] + +export interface Storage { + store( + key: string, + options: Options, + ): readonly [Store, (mutation: (draft: Value) => void) => Promise] +} + +function clone(value: Value) { + const json = JSON.stringify(value) + if (json === undefined) throw new TypeError("Storage values must be JSON-compatible objects") + const result = JSON.parse(json) as Value + if (typeof result !== "object" || result === null) throw new TypeError("Storage values must be objects") + return result as Value +} + +function segment(value: string) { + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value) || value === "." || value === "..") + throw new TypeError(`Invalid storage segment: ${value}`) + return value +} + +function createStorage(root: string, channel: string) { + const entries = new Map; readonly reload: () => void }>() + const directory = path.join(root, segment(channel), "tui") + const locks = path.join(root, segment(channel), "locks") + mkdirSync(directory, { recursive: true }) + + const storage: Storage = { + store(key: string, options: Options) { + const file = path.join(directory, segment(key) + ".json") + const existing = entries.get(file) + if (existing) return existing.value as Entry + + const load = () => { + try { + return clone(JSON.parse(readFileSync(file, "utf8")) as Value) + } catch { + return clone(options.initial) + } + } + const [store, setStore] = createStore(load()) + const reload = () => batch(() => setStore(reconcile(load()))) + const update = (mutation: (draft: Value) => void) => + Flock.withLock( + file, + async () => { + const draft = load() + mutation(draft) + const next = clone(draft) + await writeJsonAtomic(file, next) + batch(() => setStore(reconcile(next))) + }, + { dir: locks }, + ) + const entry = [store, update] as const + entries.set(file, { value: entry as Entry, reload }) + return entry + }, + } + + const watcher = watch(directory, () => entries.forEach((entry) => entry.reload())) + return { + storage, + close: () => watcher.close(), + } +} + +const Context = createContext() + +export function StorageProvider(props: ParentProps) { + const result = createStorage(path.join(useTuiPaths().state, "storage"), useTuiApp().channel) + onCleanup(result.close) + return {props.children} +} + +export function useStorage() { + const storage = useContext(Context) + if (!storage) throw new Error("StorageProvider is missing") + return storage +} diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index a5e23d0ec7..a6d8f1f4cc 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -32,6 +32,7 @@ import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useToast } from "../ui/toast" import { useAttention } from "../context/attention" +import { useStorage } from "../context/storage" import { abbreviateHome } from "../util/path-format" import { builtins } from "./builtins" import { discoverTuiPlugins } from "./discovery" @@ -96,6 +97,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const dialog = useDialog() const toast = useToast() const attention = useAttention() + const storage = useStorage() const directory = config.path ? path.dirname(config.path) : process.cwd() const [store, setStore] = createStore({ ready: false, @@ -234,6 +236,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> active: keymapState.active, mode: keymap.mode, }, + storage: { + store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options), + }, ui: { dialog: dialogApi, toast: toastApi, From 4bd16d6f47a4a2d67d6bba5c104c0fd5d6f853ad Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 12:35:23 -0400 Subject: [PATCH 25/51] feat(tui): default tabs to cwd scope --- packages/tui/src/component/dialog-config.tsx | 8 ++++++++ packages/tui/src/context/session-tabs.tsx | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 54da64c394..9eaba22bc2 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -101,6 +101,14 @@ export const settings: Setting[] = [ values: [false, true], labels: ["off", "on"], }, + { + title: "Scope", + category: "Tabs", + path: ["tabs", "scope"], + default: "cwd", + values: ["cwd", "global"], + labels: ["current directory", "global"], + }, { title: "Layout", category: "Diffs", diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index c59c00731d..0533936e4a 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -49,12 +49,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp let history: SessionTabHistory = { entries: [], index: -1 } function state() { - if (config.tabs?.scope !== "cwd") return store.global + if (config.tabs?.scope === "global") return store.global return store.cwd[paths.cwd] ?? fallback } function update(mutation: (draft: TabsState) => void) { - const scope = config.tabs?.scope ?? "global" + const scope = config.tabs?.scope ?? "cwd" void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch( () => {}, ) From 5438dfb751c3c5c1d7ff66ef9c1c33e149056275 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 12:36:42 -0400 Subject: [PATCH 26/51] fix(tui): remove invalid model toasts --- packages/tui/src/context/local.tsx | 31 +++--------------------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 96b0151931..b781b0b367 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -1,7 +1,7 @@ import { createStore } from "solid-js/store" import { dedupeWith } from "effect/Array" import { createSimpleContext } from "./helper" -import { batch, createEffect, createMemo } from "solid-js" +import { batch, createMemo } from "solid-js" import { useEvent } from "./event" import path from "path" import { useTuiPaths } from "./runtime" @@ -287,14 +287,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { batch(() => { - if (!isModelValid(model)) { - toast.show({ - message: `Model ${model.providerID}/${model.modelID} is not valid`, - variant: "warning", - duration: 3000, - }) - return - } + if (!isModelValid(model)) return const a = agent.current() if (!a) return setModelStore("model", a.id, model) @@ -306,14 +299,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, toggleFavorite(model: { providerID: string; modelID: string }) { batch(() => { - if (!isModelValid(model)) { - toast.show({ - message: `Model ${model.providerID}/${model.modelID} is not valid`, - variant: "warning", - duration: 3000, - }) - return - } + if (!isModelValid(model)) return const exists = modelStore.favorite.some( (x) => x.providerID === model.providerID && x.modelID === model.modelID, ) @@ -462,17 +448,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const session = createSession() - createEffect(() => { - const value = agent.current() - if (!value?.model) return - if (isModelValid({ providerID: value.model.providerID, modelID: value.model.id })) return - toast.show({ - variant: "warning", - message: `Agent ${value.id}'s configured model ${value.model.providerID}/${value.model.id} is not valid`, - duration: 3000, - }) - }) - const result = { model, agent, From 8f1e3ff75c03a84b76dbed7ab93117f1d6aedb60 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 12:37:00 -0400 Subject: [PATCH 27/51] docs: record v1 to v2 database migration decisions --- docs/design/v1-v2-database-migration.md | 118 ++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/design/v1-v2-database-migration.md diff --git a/docs/design/v1-v2-database-migration.md b/docs/design/v1-v2-database-migration.md new file mode 100644 index 0000000000..d03329da30 --- /dev/null +++ b/docs/design/v1-v2-database-migration.md @@ -0,0 +1,118 @@ +# V1 to V2 Database Migration + +## Approach + +- Use the `dev` branch database schema and migration registry as the V1 baseline. +- Remove migrations that exist only on the V2 branch. +- Generate one canonical migration from the `dev` schema to the final V2 schema. +- Add explicit data operations to that migration where generated DDL is insufficient. +- Test the migration against a populated database at the exact `dev` schema. + +## Preserve + +The canonical V1 data remains in its existing tables. In particular, preserve `session`, `message`, and `part` rows. + +Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild +workspace relationships. + +Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the +generated migration must not drop the table. + +## Truncate + +Truncate these pre-launch V2 tables before applying schema changes: + +- `event` +- `event_sequence` +- `session_message` + +These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the +column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message` +and `part` rows rather than retaining its pre-launch V2 contents. + +## Message Backfill + +Backfill canonical V1 history from `message` and `part` into `session_message`. This is the main data transformation in +the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the +V2 session APIs, which read `session_message`. + +Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and +avoid rewriting other persisted state that may refer to a message. + +Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign +contiguous `session_message.seq` values starting at `0`. + +Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becomes one V2 `user` row, and each ordinary +V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2 +payload. + +Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a +`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special +part mappings must be decided explicitly before implementing the backfill. + +V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user +message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes +ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an +adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior. + +Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the +admitted compaction input ID and preserves references to the initiating message. + +For a completed compaction, create one V2 `compaction` row with `status: "completed"`. Set `reason` from the V1 +compaction part's `auto` flag, join the paired summary assistant's nonempty text parts with blank lines for `summary`, and +serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an empty `recent` value when no tail was +retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2 +assistant row. + +After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that +session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting +before migrated history. The `event` table remains empty. + +## Drop + +Drop these pre-launch V2 tables without preserving or transforming their rows: + +- `session_input` +- `session_context_epoch` + +Do not transfer `session_input` rows into `session_pending`. + +## Create Empty + +Let the generated migration create these tables empty: + +- `instruction_blob` +- `instruction_entry` +- `instruction_state` +- `session_pending` +- `kv` + +V1 has no canonical data to backfill into these tables. V2 initializes their state as it runs. + +## Fork Storage + +V1 has no fork-boundary state to backfill. New V2 forks use a required message boundary and persist it in +`session.fork_boundary`. The durable fork event contains no parent sequence. Its resolved boundary is one of: + +- `before`: copy messages before the identified message. +- `through`: copy messages through the identified message. + +Forking an empty session is not supported. `session.fork_seq` and `session.fork_message_id` are not part of the final V2 +schema. + +New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit +backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns. + +## Verification + +The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts, +credentials, permissions, shares, and workspaces. After migration, it should verify: + +- Preserved rows and encoded values remain unchanged. +- Todo rows remain available in the unchanged `todo` table. +- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections. +- Backfilled `session_message` rows represent the canonical V1 `message` and `part` history. +- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence. +- Dropped tables no longer exist. +- New tables exist and are empty. +- The final schema has no ungenerated changes. From 6aa250ee5dfaeb9faa8b2210245a4eac2113f72c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 12:39:21 -0400 Subject: [PATCH 28/51] refactor(tui): flatten state storage path --- packages/tui/src/context/storage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/src/context/storage.tsx b/packages/tui/src/context/storage.tsx index 0f1303c781..40d050f69a 100644 --- a/packages/tui/src/context/storage.tsx +++ b/packages/tui/src/context/storage.tsx @@ -82,7 +82,7 @@ function createStorage(root: string, channel: string) { const Context = createContext() export function StorageProvider(props: ParentProps) { - const result = createStorage(path.join(useTuiPaths().state, "storage"), useTuiApp().channel) + const result = createStorage(useTuiPaths().state, useTuiApp().channel) onCleanup(result.close) return {props.children} } From c2e975c4e6380021cead9e5069d06857f096d7a4 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 29 Jul 2026 12:51:55 -0400 Subject: [PATCH 29/51] refactor(plugin): expose resolved TUI theme (#39536) --- bun.lock | 3 ++ packages/plugin/package.json | 5 +++ packages/plugin/src/tui/context.ts | 3 +- packages/theme/src/tui/index.ts | 3 +- packages/theme/src/tui/resolve.ts | 36 ++++++++-------- packages/theme/src/tui/syntax.ts | 4 +- packages/theme/src/tui/types.ts | 9 ++-- packages/tui/src/component/bg-pulse.tsx | 4 +- packages/tui/src/component/devtools-bar.tsx | 12 +++--- .../tui/src/component/dialog-integration.tsx | 14 +++---- packages/tui/src/component/dialog-mcp.tsx | 10 ++--- .../tui/src/component/dialog-move-session.tsx | 4 +- packages/tui/src/component/dialog-pair.tsx | 4 +- .../component/dialog-project-copy-name.tsx | 4 +- .../tui/src/component/dialog-retry-action.tsx | 4 +- .../dialog-session-delete-failed.tsx | 4 +- .../tui/src/component/dialog-session-list.tsx | 4 +- packages/tui/src/component/dialog-stash.tsx | 4 +- packages/tui/src/component/dialog-status.tsx | 4 +- .../dialog-workspace-file-changes.tsx | 6 +-- .../tui/src/component/prompt/autocomplete.tsx | 4 +- packages/tui/src/component/reconnecting.tsx | 4 +- .../tui/src/component/startup-loading.tsx | 4 +- packages/tui/src/context/theme.tsx | 41 +++++++++++-------- .../feature-plugins/system/diff-viewer.tsx | 6 ++- .../tui/src/feature-plugins/system/scrap.tsx | 2 +- packages/tui/src/plugin/context.tsx | 26 ++---------- .../tui/src/routes/session/composer/index.tsx | 4 +- packages/tui/src/routes/session/form.tsx | 4 +- packages/tui/src/routes/session/index.tsx | 10 ++--- .../tui/src/routes/session/permission.tsx | 4 +- packages/tui/src/routes/session/sidebar.tsx | 4 +- .../src/routes/session/subagent-footer.tsx | 4 +- packages/tui/src/theme/component.ts | 41 +++++++++++-------- packages/tui/src/ui/dialog-alert.tsx | 4 +- packages/tui/src/ui/dialog-confirm.tsx | 4 +- packages/tui/src/ui/dialog-export-options.tsx | 6 +-- packages/tui/src/ui/dialog-export-result.tsx | 4 +- packages/tui/src/ui/dialog-help.tsx | 4 +- packages/tui/src/ui/dialog-prompt.tsx | 4 +- packages/tui/src/ui/dialog-select.tsx | 6 +-- packages/tui/src/ui/dialog.tsx | 4 +- packages/tui/src/ui/toast.tsx | 4 +- .../cli/tui/diff-viewer-file-tree.test.tsx | 5 +-- .../tui/test/cli/tui/diff-viewer.test.tsx | 7 ++-- packages/tui/test/cli/tui/theme-mode.test.tsx | 10 +++-- packages/tui/test/theme/v2/component.test.ts | 36 ++++++++-------- packages/tui/test/theme/v2/resolve.test.ts | 30 +++++++------- packages/tui/test/theme/v2/v1-migrate.test.ts | 10 ++--- 49 files changed, 227 insertions(+), 210 deletions(-) diff --git a/bun.lock b/bun.lock index c08a107af9..0898a51c83 100644 --- a/bun.lock +++ b/bun.lock @@ -600,6 +600,7 @@ "zod": "catalog:", }, "devDependencies": { + "@opencode-ai/theme": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", @@ -611,12 +612,14 @@ "typescript": "catalog:", }, "peerDependencies": { + "@opencode-ai/theme": "workspace:*", "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5", "solid-js": ">=1.9.0", }, "optionalPeers": [ + "@opencode-ai/theme", "@opentui/core", "@opentui/keymap", "@opentui/solid", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 8f1ec0da3e..21b61cd418 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -30,12 +30,16 @@ "zod": "catalog:" }, "peerDependencies": { + "@opencode-ai/theme": "workspace:*", "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5", "solid-js": ">=1.9.0" }, "peerDependenciesMeta": { + "@opencode-ai/theme": { + "optional": true + }, "@opentui/core": { "optional": true }, @@ -50,6 +54,7 @@ } }, "devDependencies": { + "@opencode-ai/theme": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 05e66ba163..2bc5d393ca 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -19,6 +19,7 @@ import type { ShellInfo, SkillInfo, } from "@opencode-ai/client" +import type { ResolvedTheme } from "@opencode-ai/theme/tui" import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core" import type { JSX } from "@opentui/solid" import type { Store } from "solid-js/store" @@ -356,7 +357,7 @@ export interface Context { readonly client: OpenCodeClient readonly data: Data readonly attention: Attention - readonly theme: any + readonly theme: ResolvedTheme readonly keymap: Keymap readonly storage: Storage readonly ui: UI diff --git a/packages/theme/src/tui/index.ts b/packages/theme/src/tui/index.ts index 0ad7eab5ce..4bd7a99147 100644 --- a/packages/theme/src/tui/index.ts +++ b/packages/theme/src/tui/index.ts @@ -33,6 +33,7 @@ export { export type { Categorical, + ContextName, FormfieldColor, Hue, HueSource, @@ -40,7 +41,7 @@ export type { ResolvedActionState, ResolvedFormfieldState, ResolvedTheme, - ResolvedThemeView, + ResolvedThemeTokens, StatefulColor, } from "./types.js" export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js" diff --git a/packages/theme/src/tui/resolve.ts b/packages/theme/src/tui/resolve.ts index 4443e88e5d..67246a962e 100644 --- a/packages/theme/src/tui/resolve.ts +++ b/packages/theme/src/tui/resolve.ts @@ -15,11 +15,12 @@ import { } from "./schema.js" import type { ActionStateKey, + ContextName, HueDefinition, HueScale, ResolvedActionState, ResolvedTheme, - ResolvedThemeView, + ResolvedThemeTokens, StatefulColorDefinition, ThemeTokensDefinition, } from "./index.js" @@ -64,16 +65,17 @@ function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme { const hueSteps = compileHueSteps(hue) const base = tokens(definition) const resolved = resolveView(base, hue, categorical, hueSteps) - const contexts = Object.fromEntries( - Object.entries(definition) - .filter(([key]) => key.startsWith("@context:")) - .map(([key, override]) => { - const contextual = contextualize(base, override as ThemeTokensDefinition) - return [key, resolveView(contextual, hue, categorical, hueSteps)] - }), - ) + const context = (name: ContextName) => { + const override = definition[`@context:${name}`] + if (!override) return resolved + return resolveView(contextualize(base, override), hue, categorical, hueSteps) + } + const contextual = { + elevated: context("elevated"), + overlay: context("overlay"), + } - return { ...resolved, contexts } as ResolvedTheme + return { ...resolved, contextual } as ResolvedTheme } function tokens(definition: ThemeDefinition): ThemeTokensDefinition { @@ -132,15 +134,15 @@ function contextualActions( function resolveView( definition: ThemeTokensDefinition, - hue: ResolvedThemeView["hue"], - categorical: ResolvedThemeView["categorical"], - hueSteps: Pick, -): ResolvedThemeView { + hue: ResolvedThemeTokens["hue"], + categorical: ResolvedThemeTokens["categorical"], + hueSteps: Pick, +): ResolvedThemeTokens { const source: Record = { hue, ...definition } - return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, categorical, ...hueSteps } + return { ...(createResolver(source)(source, "theme") as ResolvedThemeTokens), hue, categorical, ...hueSteps } } -function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick { +function compileHueSteps(hue: ResolvedThemeTokens["hue"]): Pick { const index = new WeakMap() for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) { HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position })) @@ -201,7 +203,7 @@ function resolveHue(definition: HueDefinition) { return Object.fromEntries( [...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]), - ) as ResolvedThemeView["hue"] + ) as ResolvedThemeTokens["hue"] } function createResolver(source: Record) { diff --git a/packages/theme/src/tui/syntax.ts b/packages/theme/src/tui/syntax.ts index 6330c9913a..d85840e132 100644 --- a/packages/theme/src/tui/syntax.ts +++ b/packages/theme/src/tui/syntax.ts @@ -1,7 +1,7 @@ import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core" -import type { Mode, ResolvedThemeView } from "./index.js" +import type { Mode, ResolvedThemeTokens } from "./index.js" -export function generateSyntax(theme: ResolvedThemeView, mode: Mode) { +export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) { const step = mode === "light" ? 800 : 200 const syntax = theme.syntax const markdown = theme.markdown diff --git a/packages/theme/src/tui/types.ts b/packages/theme/src/tui/types.ts index 66e9333a9a..ceb3c151c4 100644 --- a/packages/theme/src/tui/types.ts +++ b/packages/theme/src/tui/types.ts @@ -7,7 +7,6 @@ import type { HueAlias, HueStep, MarkdownToken, - ContextKey, SyntaxToken, } from "./schema.js" @@ -20,7 +19,7 @@ export type Categorical = readonly HueScale[] export type StatefulColor = Readonly> export type FormfieldColor = StatefulColor -export type ResolvedThemeView = { +export type ResolvedThemeTokens = { readonly hue: Hue readonly categorical: Categorical readonly source: (color: RGBA) => HueSource | undefined @@ -63,6 +62,8 @@ export type ResolvedThemeView = { readonly markdown: Readonly> } -export type ResolvedTheme = ResolvedThemeView & { - readonly contexts: Readonly>> +export type ContextName = "elevated" | "overlay" + +export type ResolvedTheme = ResolvedThemeTokens & { + readonly contextual: Readonly> } diff --git a/packages/tui/src/component/bg-pulse.tsx b/packages/tui/src/component/bg-pulse.tsx index b4635a2790..01afa4c0e0 100644 --- a/packages/tui/src/component/bg-pulse.tsx +++ b/packages/tui/src/component/bg-pulse.tsx @@ -7,7 +7,7 @@ import { } from "@opentui/core" import { extend, useRenderer } from "@opentui/solid" import { onCleanup, onMount } from "solid-js" -import { useThemes } from "../context/theme" +import { useTheme, useThemes } from "../context/theme" import { tint } from "../theme/color" import { GoUpsellArtPainter } from "./bg-pulse-render" @@ -71,7 +71,7 @@ extend({ go_upsell_art: GoUpsellArtRenderable }) export function BgPulse() { const themes = useThemes() - const theme = themes.contextual("elevated") + const theme = useTheme("elevated") const mode = themes.mode const renderer = useRenderer() let targetFps = renderer.targetFps diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx index abd9525a77..f6c387971e 100644 --- a/packages/tui/src/component/devtools-bar.tsx +++ b/packages/tui/src/component/devtools-bar.tsx @@ -36,7 +36,7 @@ export function DevToolsBar() { const renderer = useRenderer() const dimensions = useTerminalDimensions() const { current: theme, mode, supports, setMode } = themes - const elevatedTheme = themes.contextual("elevated") + const elevatedTheme = useTheme("elevated") const [panel, setPanel] = createSignal() const [dumping, setDumping] = createSignal(false) const [dumpPath, setDumpPath] = createSignal() @@ -435,7 +435,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) { } function PanelBox(props: ParentProps) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const renderer = useRenderer() return ( {props.children} @@ -470,7 +470,7 @@ function PanelTitle(props: ParentProps) { } function Row(props: { label: string; value: string }) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") return ( {props.label} @@ -481,7 +481,7 @@ function Row(props: { label: string; value: string }) { } function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [hovered, setHovered] = createSignal(false) return ( { const value = props.values.at(-1) if (value === undefined) return "--" diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 5a32abe286..f07d6f27cb 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -11,7 +11,7 @@ import { useClipboard } from "../context/clipboard" import { useData } from "../context/data" import { useClient } from "../context/client" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { DialogPrompt } from "../ui/dialog-prompt" import { DialogSelect } from "../ui/dialog-select" @@ -64,7 +64,7 @@ export function DialogIntegration( ) { const data = useData() const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const options = createMemo(() => { const providers = data.location.websearch.list() ?? [] const providersByID = new Map(providers.map((provider) => [provider.id, provider])) @@ -303,8 +303,8 @@ function CommandPending(props: { function CommandView(props: { title: string; output: string; message: string }) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") - const overlayTheme = useThemes().contextual("overlay") + const theme = useTheme("elevated") + const overlayTheme = useTheme("overlay") onMount(() => dialog.setSize("large")) return ( @@ -341,7 +341,7 @@ function KeyMethod(props: { const dialog = useDialog() const client = useClient() const toast = useToast() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [error, setError] = createSignal() return ( @@ -516,7 +516,7 @@ function OAuthCode(props: { const dialog = useDialog() const client = useClient() const toast = useToast() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [error, setError] = createSignal() let settled = false @@ -561,7 +561,7 @@ function OAuthCode(props: { function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") return ( diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index 31fafd7ef8..3d3a0ab22e 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -5,7 +5,7 @@ import { Keymap } from "../context/keymap" import { pipe, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core" import type { McpServer } from "@opencode-ai/client" import { useClipboard } from "../context/clipboard" @@ -20,7 +20,7 @@ function statusError(status: McpServer["status"]) { } function Status(props: { enabled: boolean; loading: boolean }) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") if (props.loading) return ⋯ Loading if (props.enabled) { return ✓ Enabled @@ -33,7 +33,7 @@ export function DialogMcp() { const dialog = useDialog() const client = useClient() const toast = useToast() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [focused, setFocused] = createSignal() const [detail, setDetail] = createSignal() const [loading, setLoading] = createSignal(null) @@ -134,8 +134,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) { const dialog = useDialog() const clipboard = useClipboard() const toast = useToast() - const theme = useThemes().contextual("elevated") - const overlayTheme = useThemes().contextual("overlay") + const theme = useTheme("elevated") + const overlayTheme = useTheme("overlay") const dimensions = useTerminalDimensions() const config = useConfig().data const [copied, setCopied] = createSignal(false) diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 3a1171acf8..0ca6a1168c 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useClient } from "../context/client" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useData } from "../context/data" import { abbreviateHome } from "../runtime" import { useTuiPaths } from "../context/runtime" @@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const dialog = useDialog() const client = useClient() const dimensions = useTerminalDimensions() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const sessionData = useData() const route = useRoute() const toast = useToast() diff --git a/packages/tui/src/component/dialog-pair.tsx b/packages/tui/src/component/dialog-pair.tsx index 05565dd0df..70032b2e51 100644 --- a/packages/tui/src/component/dialog-pair.tsx +++ b/packages/tui/src/component/dialog-pair.tsx @@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid" import { createMemo, createResource, createSignal, For, Show } from "solid-js" import { renderUnicodeCompact } from "uqr" import { useClient } from "../context/client" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { errorMessage } from "../util/error" @@ -16,7 +16,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { const client = useClient() const dialog = useDialog() const dimensions = useTerminalDimensions() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [loadError, setLoadError] = createSignal() const [showPassword, setShowPassword] = createSignal(false) const [passwordHover, setPasswordHover] = createSignal(false) diff --git a/packages/tui/src/component/dialog-project-copy-name.tsx b/packages/tui/src/component/dialog-project-copy-name.tsx index d172b22456..1be66ab5bb 100644 --- a/packages/tui/src/component/dialog-project-copy-name.tsx +++ b/packages/tui/src/component/dialog-project-copy-name.tsx @@ -2,12 +2,12 @@ import { InputRenderable, TextAttributes } from "@opentui/core" import { Slug } from "@opencode-ai/core/util/slug" import { createSignal, onMount } from "solid-js" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "../ui/dialog" export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const shortcuts = Keymap.useShortcuts() const [inputTarget, setInputTarget] = createSignal() let input: InputRenderable diff --git a/packages/tui/src/component/dialog-retry-action.tsx b/packages/tui/src/component/dialog-retry-action.tsx index b98ba9580c..65d7730ac4 100644 --- a/packages/tui/src/component/dialog-retry-action.tsx +++ b/packages/tui/src/component/dialog-retry-action.tsx @@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core" import open from "open" import { createSignal } from "solid-js" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "../ui/dialog" import { Link } from "../ui/link" import { BgPulse } from "./bg-pulse" @@ -38,7 +38,7 @@ function panelOverlay(color: RGBA) { export function DialogRetryAction(props: DialogRetryActionProps) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const showGoTreatment = () => props.link === GO_URL const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined) const [selected, setSelected] = createSignal<"dismiss" | "action">("action") diff --git a/packages/tui/src/component/dialog-session-delete-failed.tsx b/packages/tui/src/component/dialog-session-delete-failed.tsx index d11b113a7f..2d44512d9c 100644 --- a/packages/tui/src/component/dialog-session-delete-failed.tsx +++ b/packages/tui/src/component/dialog-session-delete-failed.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" @@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: { onDone?: () => void }) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [store, setStore] = createStore({ active: "delete" as "delete" | "restore", }) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 3f572ff39a..3b489607b1 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -7,7 +7,7 @@ import { useRoute } from "../context/route" import { useData } from "../context/data" import { Keymap } from "../context/keymap" import { Locale } from "../util/locale" -import { useThemes } from "../context/theme" +import { useTheme, useThemes } from "../context/theme" import { useClient } from "../context/client" import { useLocal } from "../context/local" import { createDebouncedSignal } from "../util/signal" @@ -22,7 +22,7 @@ export function DialogSessionList() { const route = useRoute() const data = useData() const themes = useThemes() - const theme = themes.contextual("elevated") + const theme = useTheme("elevated") const mode = themes.mode const client = useClient() const local = useLocal() diff --git a/packages/tui/src/component/dialog-stash.tsx b/packages/tui/src/component/dialog-stash.tsx index 90c2dc8e53..176bb71928 100644 --- a/packages/tui/src/component/dialog-stash.tsx +++ b/packages/tui/src/component/dialog-stash.tsx @@ -3,7 +3,7 @@ import { DialogSelect } from "../ui/dialog-select" import { createMemo, createSignal } from "solid-js" import { Locale } from "../util/locale" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { usePromptStash, type StashEntry } from "./prompt/stash" function getRelativeTime(timestamp: number): string { @@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string { export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const dialog = useDialog() const stash = usePromptStash() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const shortcuts = Keymap.useShortcuts() const [toDelete, setToDelete] = createSignal() diff --git a/packages/tui/src/component/dialog-status.tsx b/packages/tui/src/component/dialog-status.tsx index 2e9675c807..b848176958 100644 --- a/packages/tui/src/component/dialog-status.tsx +++ b/packages/tui/src/component/dialog-status.tsx @@ -1,5 +1,5 @@ import { TextAttributes } from "@opentui/core" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { useData } from "../context/data" import { For, Match, Switch, Show, createMemo } from "solid-js" @@ -8,7 +8,7 @@ export type DialogStatusProps = {} export function DialogStatus() { const data = useData() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const dialog = useDialog() const mcp = createMemo(() => data.location.mcp.server.list() ?? []) diff --git a/packages/tui/src/component/dialog-workspace-file-changes.tsx b/packages/tui/src/component/dialog-workspace-file-changes.tsx index d349923e05..89eb3c75d0 100644 --- a/packages/tui/src/component/dialog-workspace-file-changes.tsx +++ b/packages/tui/src/component/dialog-workspace-file-changes.tsx @@ -4,7 +4,7 @@ import type { VcsFileStatus } from "@opencode-ai/client" import { createMemo, For } from "solid-js" import { createStore } from "solid-js/store" import { FilePath } from "../ui/file-path" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useConfig } from "../config" import { useDialog, type DialogContext } from "../ui/dialog" import { getScrollAcceleration } from "../util/scroll" @@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: { message?: string }) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") - const overlayTheme = useThemes().contextual("overlay") + const theme = useTheme("elevated") + const overlayTheme = useTheme("overlay") const config = useConfig().data const dimensions = useTerminalDimensions() const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 10c1f037dd..8912a052b3 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll" import { useTuiPaths } from "../../context/runtime" import { useConfig } from "../../config" import { useLocation } from "../../context/location" -import { useThemes } from "../../context/theme" +import { useTheme } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "../../util/locale" @@ -57,7 +57,7 @@ export function Autocomplete(props: { const data = useData() const keymap = Keymap.use() const keymapCommands = Keymap.useCommands() - const theme = useThemes().contextual("overlay") + const theme = useTheme("overlay") const dimensions = useTerminalDimensions() const frecency = useFrecency() const config = useConfig().data diff --git a/packages/tui/src/component/reconnecting.tsx b/packages/tui/src/component/reconnecting.tsx index a61e319bd2..dca869569d 100644 --- a/packages/tui/src/component/reconnecting.tsx +++ b/packages/tui/src/component/reconnecting.tsx @@ -1,9 +1,9 @@ import { RGBA } from "@opentui/core" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { Spinner } from "./spinner" export function Reconnecting() { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") return ( boolean }) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [show, setShow] = createSignal(false) const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins...")) let wait: NodeJS.Timeout | undefined diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index a84826f36a..4a13c9eda2 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -1,6 +1,12 @@ import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core" import { useRenderer } from "@opentui/solid" -import { generateSyntax, resolveThemeDocument, themeModes } from "@opencode-ai/theme/tui" +import { + generateSyntax, + resolveThemeDocument, + themeModes, + type ResolvedTheme, + type ContextName, +} from "@opencode-ai/theme/tui" import { DEFAULT_THEMES, addTheme, @@ -95,10 +101,9 @@ type State = { ready: boolean } -type ContextName = "elevated" | "overlay" type Themes = { current: ComponentTheme - contextual(context: ContextName): ComponentTheme + currentTokens: Accessor readonly selected: string all: typeof allThemes has: typeof hasTheme @@ -115,6 +120,12 @@ type Themes = { readonly ready: boolean } +type ThemeContextValue = { + current: ComponentTheme["contextual"][ContextName] + themes: Themes + readonly ready: boolean +} + const [store, setStore] = createStore({ themes: allThemes(), mode: "dark", @@ -127,7 +138,7 @@ subscribeThemes((themes) => setStore("themes", themes)) const themeContext = createSimpleContext({ name: "Theme", - init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => { + init: (props: { mode: "dark" | "light"; source?: ThemeSource }): ThemeContextValue => { const renderer = useRenderer() const configState = useConfig() const config = configState.data @@ -309,21 +320,14 @@ const themeContext = createSimpleContext({ valuesV2() themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`) const current = createComponentTheme(valuesV2, mode) - const contextsV2 = { - elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode), - overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode), - } createEffect(() => renderer.setBackgroundColor(valuesV2().background.default)) const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode())) - function contextual(context: ContextName) { - return contextsV2[context] - } const service: Themes = { current, + currentTokens: valuesV2, currentSyntax, - contextual, get selected() { return store.active }, @@ -368,15 +372,20 @@ const themeContext = createSimpleContext({ export function useThemes() { return themeContext.use().themes } -export function useTheme() { - return themeContext.use().current +export function useTheme(): ComponentTheme +export function useTheme(context: ContextName): ComponentTheme["contextual"][ContextName] +export function useTheme(context?: ContextName) { + const value = themeContext.use() + return context ? value.themes.current.contextual[context] : value.current } export const ThemeProvider = themeContext.provider export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) { - const themes = useThemes() + const value = themeContext.use() return ( - + {props.children} ) diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index 51055b6c10..279808d184 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -18,6 +18,7 @@ import { Panel, PanelGroup, Separator } from "./diff-viewer-ui" import { DialogSelect } from "../../ui/dialog-select" import { getScrollAcceleration } from "../../util/scroll" import { useConfig } from "../../config" +import { useThemes } from "../../context/theme" import { allExpandedFileTreeDirectories, buildFileTree, @@ -83,6 +84,7 @@ function DiffViewer(props: { context: Plugin.Context }) { const config = useConfig() const dialog = props.context.ui.dialog const theme = props.context.theme + const currentSyntax = useThemes().currentSyntax const params = () => { const route = props.context.ui.router.current() return (route.type === "plugin" ? route.data : undefined) as @@ -834,7 +836,7 @@ function DiffViewer(props: { context: Plugin.Context }) { diff={patch()} view={view()} filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)} - syntaxStyle={theme.syntaxStyle()} + syntaxStyle={currentSyntax()} showLineNumbers={true} width="100%" wrapMode="char" @@ -941,7 +943,7 @@ function DiffViewer(props: { context: Plugin.Context }) { } function DiffViewerHelpDialog(props: { context: Plugin.Context }) { - const theme = props.context.theme.contextual("elevated") + const theme = props.context.theme.contextual.elevated const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0] const rows = [ { diff --git a/packages/tui/src/feature-plugins/system/scrap.tsx b/packages/tui/src/feature-plugins/system/scrap.tsx index 9305845c49..78a483d04c 100644 --- a/packages/tui/src/feature-plugins/system/scrap.tsx +++ b/packages/tui/src/feature-plugins/system/scrap.tsx @@ -44,7 +44,7 @@ function Commands(props: { context: Plugin.Context }) { function Scrap(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() const theme = props.context.theme - const elevatedTheme = props.context.theme.contextual("elevated") + const elevatedTheme = theme.contextual.elevated const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6)) const [active, setActive] = createSignal("fixture-2") const [animations, setAnimations] = createSignal(true) diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index a6d8f1f4cc..6dc8df9729 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -24,7 +24,7 @@ import { Keymap } from "../context/keymap" import { useRoute } from "../context/route" import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime" import { useLocation } from "../context/location" -import { useTheme, useThemes } from "../context/theme" +import { useThemes } from "../context/theme" import { DialogAlert } from "../ui/dialog-alert" import { DialogConfirm } from "../ui/dialog-confirm" import { DialogPrompt } from "../ui/dialog-prompt" @@ -91,9 +91,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const app = useTuiApp() const paths = useTuiPaths() const location = useLocation() - const theme = useTheme() const themes = useThemes() - const pluginTheme = createPluginTheme(theme, themes) const dialog = useDialog() const toast = useToast() const attention = useAttention() @@ -226,7 +224,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> client: client.api, data, attention, - theme: pluginTheme, + get theme() { + return themes.currentTokens() + }, keymap: { layer: Keymap.createLayer, dispatch: keymap.dispatch, @@ -530,24 +530,6 @@ function isPlugin(value: unknown): value is Plugin.Definition { ) } -type PluginTheme = ReturnType & { - contextual(context: "elevated" | "overlay"): PluginTheme - syntaxStyle(): ReturnType["currentSyntax"]> -} - -export function createPluginTheme(theme: ReturnType, themes: ReturnType): PluginTheme { - return new Proxy(theme as PluginTheme, { - get(target, property, receiver) { - if (property === "contextual") { - return (context: "elevated" | "overlay") => createPluginTheme(themes.contextual(context), themes) - } - if (property === "syntaxStyle") return themes.currentSyntax - if (Reflect.has(target, property)) return Reflect.get(target, property, receiver) - return Reflect.get(themes, property, themes) - }, - }) -} - export function usePlugin() { const value = useContext(PluginContext) if (!value) throw new Error("PluginProvider is missing") diff --git a/packages/tui/src/routes/session/composer/index.tsx b/packages/tui/src/routes/session/composer/index.tsx index d29e206be4..08d3d22473 100644 --- a/packages/tui/src/routes/session/composer/index.tsx +++ b/packages/tui/src/routes/session/composer/index.tsx @@ -1,7 +1,7 @@ import { createEffect, createMemo, For, onCleanup, Show, useContext, createContext } from "solid-js" import { createStore } from "solid-js/store" import { TextAttributes } from "@opentui/core" -import { useThemes } from "../../../context/theme" +import { useTheme } from "../../../context/theme" import { SplitBorder } from "../../../ui/border" import { Keymap } from "../../../context/keymap" import { SubagentsTab } from "./subagents-tab" @@ -39,7 +39,7 @@ export type ComposerProps = { } export function Composer(props: ComposerProps) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [store, setStore] = createStore({ tabs: {} as Record, diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 976fca4824..8ea3c6f8c9 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j import { useRenderer, useTerminalDimensions } from "@opentui/solid" import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" import open from "open" -import { useThemes } from "../../context/theme" +import { useTheme, useThemes } from "../../context/theme" import type { FormField, FormValue } from "@opencode-ai/client" import type { FormWithLocation } from "../../context/data" import { useClient } from "../../context/client" @@ -45,7 +45,7 @@ function requestOptions(form: FormWithLocation) { export function FormPrompt(props: { form: FormWithLocation }) { const client = useClient() const themes = useThemes() - const theme = themes.contextual("elevated") + const theme = useTheme("elevated") const themeMode = themes.mode const renderer = useRenderer() const dimensions = useTerminalDimensions() diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index fedda0d98c..0f3ade0541 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1494,7 +1494,7 @@ function SessionGroupView(props: { function AssistantFooter(props: { message: SessionMessageAssistant }) { const ctx = use() const local = useLocal() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const model = createMemo( () => ctx @@ -1691,7 +1691,7 @@ function RevertMessage(props: { }> }) { const ctx = use() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const route = useRouteData("session") const client = useClient() const toast = useToast() @@ -1764,7 +1764,7 @@ function RevertMessage(props: { } function ShellMessage(props: { message: Extract }) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? "")) return ( @@ -1792,7 +1792,7 @@ function UserMessage(props: { message: SessionMessageUser }) { const local = useLocal() const files = createMemo(() => props.message.files ?? []) const themes = useThemes() - const theme = themes.contextual("elevated") + const theme = useTheme("elevated") const mode = themes.mode const [hover, setHover] = createSignal(false) const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build")) @@ -1869,7 +1869,7 @@ function UserMessage(props: { message: SessionMessageUser }) { function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) { const ctx = use() const local = useLocal() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const model = createMemo( () => ctx diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 663c4edf01..5cd7966f8a 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -297,7 +297,7 @@ function RejectPrompt(props: { onCancel: () => void }) { let input: TextareaRenderable - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) Keymap.createLayer(() => ({ @@ -429,7 +429,7 @@ function Prompt>(props: { fullscreen?: boolean onSelect: (option: keyof T) => void }) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 44c0130f6c..3de0335463 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -1,6 +1,6 @@ import { useData } from "../../context/data" import { createMemo, Show } from "solid-js" -import { useThemes } from "../../context/theme" +import { useTheme } from "../../context/theme" import { useConfig } from "../../config" import { PluginSlot } from "../../plugin/context" @@ -8,7 +8,7 @@ import { getScrollAcceleration } from "../../util/scroll" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const data = useData() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const config = useConfig().data const session = createMemo(() => data.session.get(props.sessionID)) const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index a0ba4971f4..9a9722db7f 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -1,7 +1,7 @@ import { createMemo, createSignal, Show } from "solid-js" import { useRouteData } from "../../context/route" import { useData } from "../../context/data" -import { useThemes } from "../../context/theme" +import { useTheme } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { Locale } from "../../util/locale" import { useTerminalDimensions } from "@opentui/solid" @@ -42,7 +42,7 @@ export function SubagentFooter() { } }) - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const keymap = Keymap.use() const shortcuts = Keymap.useShortcuts() const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) diff --git a/packages/tui/src/theme/component.ts b/packages/tui/src/theme/component.ts index 28e52658d7..8e9cdffcb3 100644 --- a/packages/tui/src/theme/component.ts +++ b/packages/tui/src/theme/component.ts @@ -1,41 +1,48 @@ import type { RGBA } from "@opentui/core" import type { Accessor } from "solid-js" -import type { Mode, ResolvedThemeView } from "@opencode-ai/theme/tui" +import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode-ai/theme/tui" -export function createComponentTheme(current: Accessor, mode: Accessor) { - return { +export function createComponentTheme(current: Accessor, mode: Accessor) { + const create = (view: Accessor) => ({ get hue() { - return current().hue + return view().hue }, get categorical() { - return current().categorical + return view().categorical }, get text() { - return current().text + return view().text }, get background() { - return current().background + return view().background }, get border() { - return current().border + return view().border }, get scrollbar() { - return current().scrollbar + return view().scrollbar }, get diff() { - return current().diff + return view().diff }, get syntax() { - return current().syntax + return view().syntax }, get markdown() { - return current().markdown + return view().markdown }, - source: (color: RGBA) => current().source(color), - increase: (color: RGBA, amount = 1) => current().increase(color, amount), - decrease: (color: RGBA, amount = 1) => current().decrease(color, amount), - raise: (color: RGBA) => (mode() === "light" ? current().increase(color) : current().decrease(color)), - } + source: (color: RGBA) => view().source(color), + increase: (color: RGBA, amount = 1) => view().increase(color, amount), + decrease: (color: RGBA, amount = 1) => view().decrease(color, amount), + raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)), + }) + + return Object.assign(create(current), { + contextual: { + elevated: create(() => current().contextual.elevated), + overlay: create(() => current().contextual.overlay), + }, + }) } export type ComponentTheme = ReturnType diff --git a/packages/tui/src/ui/dialog-alert.tsx b/packages/tui/src/ui/dialog-alert.tsx index f7c231a07f..718ec0c4d5 100644 --- a/packages/tui/src/ui/dialog-alert.tsx +++ b/packages/tui/src/ui/dialog-alert.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" export type DialogAlertProps = { @@ -11,7 +11,7 @@ export type DialogAlertProps = { export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") Keymap.createLayer(() => ({ mode: "modal", diff --git a/packages/tui/src/ui/dialog-confirm.tsx b/packages/tui/src/ui/dialog-confirm.tsx index 5adbb0333e..35cb757d23 100644 --- a/packages/tui/src/ui/dialog-confirm.tsx +++ b/packages/tui/src/ui/dialog-confirm.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" @@ -21,7 +21,7 @@ export type DialogConfirmResult = boolean | undefined export function DialogConfirm(props: DialogConfirmProps) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [store, setStore] = createStore({ active: "confirm" as "confirm" | "cancel", }) diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index dc9049a525..9953d06fad 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { For, Show } from "solid-js" @@ -17,8 +17,8 @@ type Active = ExportFormat | "thinking" | "copy" | "export" export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") - const overlayTheme = useThemes().contextual("overlay") + const theme = useTheme("elevated") + const overlayTheme = useTheme("overlay") const [store, setStore] = createStore({ format: "markdown" as ExportFormat, thinking: props.defaultThinking, diff --git a/packages/tui/src/ui/dialog-export-result.tsx b/packages/tui/src/ui/dialog-export-result.tsx index c43b7ef2e1..9ae5d5cc1c 100644 --- a/packages/tui/src/ui/dialog-export-result.tsx +++ b/packages/tui/src/ui/dialog-export-result.tsx @@ -1,11 +1,11 @@ import { TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" export function DialogExportResult(props: { path: string; onClose?: () => void }) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const close = () => { props.onClose?.() diff --git a/packages/tui/src/ui/dialog-help.tsx b/packages/tui/src/ui/dialog-help.tsx index 9217d71f6a..ebf05b5e5a 100644 --- a/packages/tui/src/ui/dialog-help.tsx +++ b/packages/tui/src/ui/dialog-help.tsx @@ -1,11 +1,11 @@ import { TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog } from "./dialog" export function DialogHelp() { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const shortcuts = Keymap.useShortcuts() Keymap.createLayer(() => ({ diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index acb1700d26..9eb0ec0f4f 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -1,6 +1,6 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js" import { Spinner } from "../component/spinner" @@ -18,7 +18,7 @@ export type DialogPromptProps = { export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const shortcuts = Keymap.useShortcuts() const [textareaTarget, setTextareaTarget] = createSignal() let textarea: TextareaRenderable diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 7b2d27ffe0..c10e44ad2f 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -1,6 +1,6 @@ import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { Keymap, type KeymapCommand } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme, useThemes } from "../context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" @@ -96,7 +96,7 @@ export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const themes = useThemes() - const theme = themes.contextual("elevated") + const theme = useTheme("elevated") const mode = themes.mode const config = useConfig().data const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) @@ -773,7 +773,7 @@ function Option(props: { activeColor?: RGBA onMouseOver?: () => void }) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const text = createMemo(() => { if (props.active && !props.muted) return props.activeColor ?? theme.text.action.primary.focused if (props.muted && (props.active || props.current)) return theme.text.subdued diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index b0d885d50c..0c55bbae5b 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -1,7 +1,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { MouseButton, Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" import { useToast } from "./toast" @@ -16,7 +16,7 @@ export function Dialog( }>, ) { const dimensions = useTerminalDimensions() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const renderer = useRenderer() let dismiss = false diff --git a/packages/tui/src/ui/toast.tsx b/packages/tui/src/ui/toast.tsx index 821e6ae12b..9c54ad8cfa 100644 --- a/packages/tui/src/ui/toast.tsx +++ b/packages/tui/src/ui/toast.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, type ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useTerminalDimensions } from "@opentui/solid" import { SplitBorder } from "./border" import { TextAttributes } from "@opentui/core" @@ -14,7 +14,7 @@ type ToastInput = Omit & { duration?: number } export function Toast() { const toast = useToast() - const theme = useThemes().contextual("overlay") + const theme = useTheme("overlay") const dimensions = useTerminalDimensions() return ( diff --git a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx index aa930029f5..bc2a51cb6b 100644 --- a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx @@ -4,7 +4,7 @@ import { testRender } from "@opentui/solid" import type { JSX } from "solid-js" import { onMount, type ParentProps } from "solid-js" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" -import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme" +import { ThemeProvider, useThemes } from "../../../src/context/theme" import type { Plugin } from "@opencode-ai/plugin/tui" import { ConfigProvider } from "../../../src/config" import { @@ -12,7 +12,6 @@ import { type DiffViewerFileTreeProps, } from "../../../src/feature-plugins/system/diff-viewer-file-tree" import { TestTuiContexts } from "../../fixture/tui-environment" -import { createPluginTheme } from "../../../src/plugin/context" import { allExpandedFileTreeDirectories, buildFileTree, @@ -130,7 +129,7 @@ describe("DiffViewerFileTree", () => { }) function ThemedDiffViewerFileTree(props: Omit) { - return + return } async function renderFrame(component: () => JSX.Element) { diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index 04758facb9..89937c032f 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -11,7 +11,7 @@ import type { Route, Slot, } from "@opencode-ai/plugin/tui/context" -import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme" +import { ThemeProvider, useThemes } from "../../../src/context/theme" import { ConfigProvider } from "../../../src/config" import { TuiKeybind } from "../../../src/config/keybind" import { Keymap } from "../../../src/context/keymap" @@ -21,7 +21,6 @@ import { TestTuiContexts } from "../../fixture/tui-environment" import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" import { DialogProvider } from "../../../src/ui/dialog" import { ToastProvider } from "../../../src/ui/toast" -import { createPluginTheme } from "../../../src/plugin/context" test("closing the diff viewer returns to the route it opened from", async () => { const viewer = await renderDiffViewer([]) @@ -158,7 +157,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: }) }, createEventStream()) function Harness() { - let theme: ReturnType + let theme: ReturnType["currentTokens"]> const context = { options: {}, client: createApi(transport.fetch), @@ -207,7 +206,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: void diffViewerPlugin.setup(context) function Content() { - theme = createPluginTheme(useTheme(), useThemes()) + theme = useThemes().currentTokens() const commandView = renderCommands?.({}) if (current.type !== "plugin") commands.get("diff.open")?.run() return ( diff --git a/packages/tui/test/cli/tui/theme-mode.test.tsx b/packages/tui/test/cli/tui/theme-mode.test.tsx index 7e59846bfb..4ab2232213 100644 --- a/packages/tui/test/cli/tui/theme-mode.test.tsx +++ b/packages/tui/test/cli/tui/theme-mode.test.tsx @@ -6,7 +6,7 @@ import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { DEFAULT_THEMES } from "../../../src/theme" import { ConfigProvider } from "../../../src/config" -import { ThemeContextProvider, ThemeProvider, useTheme, useThemes, type ThemeError } from "../../../src/context/theme" +import { ThemeContextProvider, ThemeProvider, type ThemeError, useTheme, useThemes } from "../../../src/context/theme" async function wait(fn: () => boolean) { const started = Date.now() @@ -129,9 +129,11 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b } as const let themes: ReturnType | undefined let theme: ReturnType | undefined + let explicit: ReturnType | undefined function ContextProbe() { theme = useTheme() + explicit = useTheme("elevated") return {theme.text.default.toString()} } @@ -160,9 +162,11 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b await wait(() => themes?.ready === true) if (!themes) throw new Error("Theme provider is not mounted") if (!theme) throw new Error("Contextual theme is not mounted") + if (!explicit) throw new Error("Explicit contextual theme is not mounted") expect(theme.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue() - expect(theme.text.default).toBe(themes.contextual("elevated").text.default) - expect(themes.contextual("overlay").background.default).toBe(themes.current.background.default) + expect(theme).toBe(explicit) + expect(theme.text.default).toBe(themes.current.contextual.elevated.text.default) + expect(themes.current.contextual.overlay.background.default).toBe(themes.current.background.default) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts index 87c3265777..4d3eeb3d00 100644 --- a/packages/tui/test/theme/v2/component.test.ts +++ b/packages/tui/test/theme/v2/component.test.ts @@ -1,17 +1,18 @@ import { expect, test } from "bun:test" import { createSignal } from "solid-js" import { RGBA } from "@opentui/core" -import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextKey } from "@opencode-ai/theme/tui" +import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextName } from "@opencode-ai/theme/tui" import { createComponentTheme } from "../../../src/theme/component" test("provides reactive properties, states, contexts, and color operations", () => { const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light"))) const [mode, setMode] = createSignal<"light" | "dark">("light") - const [context, setContext] = createSignal() - const theme = createComponentTheme(() => { - const key = context() - return key ? (resolved().contexts[key] ?? resolved()) : resolved() - }, mode) + const theme = createComponentTheme(resolved, mode) + const [context, setContext] = createSignal() + const current = () => { + const name = context() + return name ? theme.contextual[name] : theme + } expect(theme.text.default).toBe(resolved().text.default) expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500]) @@ -50,20 +51,21 @@ test("provides reactive properties, states, contexts, and color operations", () expect(theme.scrollbar.default).toBe(resolved().scrollbar.default) expect(theme.diff.text.added).toBe(resolved().diff.text.added) - setContext("@context:elevated") - expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500])) - expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default) - expect(theme.background.action.primary.focused).toBe( - resolved().contexts["@context:elevated"]!.background.action.primary.focused, + setContext("elevated") + expect("contexts" in current()).toBeFalse() + expect(current().categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500])) + expect(current().text.default).toBe(resolved().contextual.elevated.text.default) + expect(current().background.action.primary.focused).toBe( + resolved().contextual.elevated.background.action.primary.focused, ) - expect(theme.background.action.primary.hovered).toBe(resolved().background.surface.overlay) - expect(theme.background.formfield.selected).toBe( - resolved().contexts["@context:elevated"]!.background.formfield.selected, + expect(current().background.action.primary.hovered).toBe(resolved().background.surface.overlay) + expect(current().background.formfield.selected).toBe( + resolved().contextual.elevated.background.formfield.selected, ) setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark"))) setMode("dark") - expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default) - expect(theme.decrease(theme.background.surface.offset, 1)).toBe(resolved().hue.neutral[600]) - expect(theme.raise(theme.background.surface.offset)).toBe(resolved().hue.neutral[600]) + expect(current().text.default).toBe(resolved().contextual.elevated.text.default) + expect(current().decrease(current().background.surface.offset, 1)).toBe(resolved().hue.neutral[600]) + expect(current().raise(current().background.surface.offset)).toBe(resolved().hue.neutral[600]) }) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index f3e3837de6..ce2fe09d2f 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -37,7 +37,7 @@ test("validates and resolves categorical hues in configured order", () => { expect(theme.categorical[0]).toBe(theme.hue.accent) expect(theme.categorical[1]).toBe(theme.hue.red) expect(theme.categorical[2]).toBe(theme.hue.interactive) - expect(theme.contexts["@context:elevated"]?.categorical).toBe(theme.categorical) + expect(theme.contextual.elevated.categorical).toBe(theme.categorical) expect(() => resolveSource({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme") expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme") }) @@ -65,29 +65,29 @@ test("resolves independent definitions and hue aliases", () => { expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 }) expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100]) - expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) + expect(lightTheme.contextual.elevated.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) expect(lightTheme.text.default).toBeInstanceOf(RGBA) expect(darkTheme.background.default).toBeInstanceOf(RGBA) expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[300]) expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[400]) expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA) expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200]) - expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe( + expect(lightTheme.contextual.elevated.background.action.primary.default).toBe( lightTheme.hue.interactive[500], ) - expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset) - expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) - expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe( + expect(lightTheme.contextual.elevated.background.default).toBe(lightTheme.background.surface.offset) + expect(lightTheme.contextual.elevated.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) + expect(lightTheme.contextual.overlay.background.action.primary.default).toBe( lightTheme.hue.interactive[500], ) - expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay) - expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) - expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe( + expect(lightTheme.contextual.overlay.background.default).toBe(lightTheme.background.surface.overlay) + expect(lightTheme.contextual.overlay.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) + expect(darkTheme.contextual.elevated.background.action.primary.default).toBe( darkTheme.hue.interactive[400], ) - expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) - expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(darkTheme.hue.interactive[400]) - expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) + expect(darkTheme.contextual.elevated.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) + expect(darkTheme.contextual.overlay.background.action.primary.default).toBe(darkTheme.hue.interactive[400]) + expect(darkTheme.contextual.overlay.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) }) test("resolves base hue aliases and rejects circular hue aliases", () => { @@ -228,8 +228,8 @@ test("resolves elevated hover surfaces from direct colors", () => { "light", ) - expect(theme.contexts["@context:elevated"]?.background.default.toInts()).toEqual([18, 52, 86, 255]) - expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255]) + expect(theme.contextual.elevated.background.default.toInts()).toEqual([18, 52, 86, 255]) + expect(theme.contextual.elevated.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255]) }) test("resolves transparent colors", () => { @@ -271,7 +271,7 @@ test("context overrides rewire semantic references and apply state precedence", }, }) const theme = resolveTheme(definition) - const overlay = theme.contexts["@context:elevated"]! + const overlay = theme.contextual.elevated expect(overlay.text.default.toInts()).toEqual([51, 51, 51, 255]) expect(overlay.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255]) diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts index 31b5b25e5d..677943414f 100644 --- a/packages/tui/test/theme/v2/v1-migrate.test.ts +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -43,11 +43,11 @@ test("migrates resolved V1 modes into V2 tokens", () => { expect(resolved.background.action.primary.selected.toInts()).toEqual([0, 0, 0, 0]) expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts()) expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts()) - expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts()) - expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0]) - expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(legacy.text.toInts()) - expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts()) - expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0]) + expect(resolved.contextual.elevated.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts()) + expect(resolved.contextual.elevated.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0]) + expect(resolved.contextual.elevated.text.action.primary.default.toInts()).toEqual(legacy.text.toInts()) + expect(resolved.contextual.overlay.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts()) + expect(resolved.contextual.overlay.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0]) }) test("references generated hues from matching token colors", () => { From b2010220f99b6e58f496bd0a52bf3c9906b28137 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:56:53 -0500 Subject: [PATCH 30/51] fix(core): clarify Code Mode tool boundary (#39540) --- packages/core/src/codemode/instructions.ts | 4 +++- packages/core/src/codemode/tool.ts | 2 +- packages/core/test/codemode/catalog.test.ts | 4 ++++ packages/core/test/codemode/instructions.test.ts | 3 +++ packages/core/test/tool-execute.test.ts | 2 +- 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/core/src/codemode/instructions.ts b/packages/core/src/codemode/instructions.ts index 7e5d135edb..50c432fb33 100644 --- a/packages/core/src/codemode/instructions.ts +++ b/packages/core/src/codemode/instructions.ts @@ -6,7 +6,9 @@ import { Instructions } from "../instructions/index" import { CodeModeCatalog } from "./catalog" // prettier-ignore -const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? ` +const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}. + +${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? ` ## Search diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index 7d9c5106be..b0e6d7b319 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -33,7 +33,7 @@ type CollectedFiles = { // Invariant model-facing guidance; the changing tool catalog is delivered through Instructions. const description = [ - "Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.", + "Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.", "Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.", "Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.", 'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.["tool-name"](input)`.', diff --git a/packages/core/test/codemode/catalog.test.ts b/packages/core/test/codemode/catalog.test.ts index e12df20085..1168c7ee67 100644 --- a/packages/core/test/codemode/catalog.test.ts +++ b/packages/core/test/codemode/catalog.test.ts @@ -67,6 +67,7 @@ describe("CodeModeInstructions.render", () => { expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`) expect(instructions).not.toContain("## Search") expect(instructions).toContain("The Code Mode tool catalog below is complete.") + expect(instructions).toContain("This catalog is the complete set of tools available within Code Mode.") expect(instructions).not.toContain("surrounding top-level agent tools") }) @@ -76,6 +77,9 @@ describe("CodeModeInstructions.render", () => { expect(partial).toContain("- orders (1 tool, none shown)") expect(partial).toContain("## Search") expect(partial).toContain("The Code Mode tool catalog below is partial.") + expect(partial).toContain( + "The Code Mode catalog and `search` results are the complete set of tools available within Code Mode.", + ) expect(partial).not.toContain("surrounding top-level agent tools") expect(partial).toContain("- search(input: {") expect(partial).toContain(" limit?: number,\n offset?: number,") diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts index 543938c268..f902e647dd 100644 --- a/packages/core/test/codemode/instructions.test.ts +++ b/packages/core/test/codemode/instructions.test.ts @@ -44,6 +44,9 @@ describe("CodeModeInstructions", () => { it.effect("renders the initial catalog, semantic deltas, and removal", () => Effect.gen(function* () { const initialized = yield* readInitial(CodeModeInstructions.make([echo])) + expect(initialized.text).toContain( + "This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.", + ) expect(initialized.text).toContain("## Available tools") expect(initialized.text).not.toContain("## Search") expect(initialized.text).toContain(` - ${echo.signature} // Echo text`) diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index d679f317fe..fcfcb7aea4 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -22,7 +22,7 @@ const createCodeMode = (tools: ReadonlyMap) => test("execute describes invariant Code Mode behavior", () => { expect(createCodeMode(new Map()).description).toBe( [ - "Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.", + "Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.", "Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.", "Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.", 'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.["tool-name"](input)`.', From f599f8f3d3022deb496bc238d0d3694f1ce5df9d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 12:57:12 -0400 Subject: [PATCH 31/51] fix(tui): guard Bun runtime plugin support --- packages/tui/package.json | 5 +++++ packages/tui/src/plugin/context.tsx | 4 +--- packages/tui/src/plugin/runtime-plugin-support.bun.ts | 3 +++ packages/tui/src/plugin/runtime-plugin-support.node.ts | 1 + 4 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 packages/tui/src/plugin/runtime-plugin-support.bun.ts create mode 100644 packages/tui/src/plugin/runtime-plugin-support.node.ts diff --git a/packages/tui/package.json b/packages/tui/package.json index 8ae0222ebe..5fa63c5a76 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -72,6 +72,11 @@ "bun": "./src/editor-zed-sqlite.bun.ts", "node": "./src/editor-zed-sqlite.node.ts", "default": "./src/editor-zed-sqlite.bun.ts" + }, + "#runtime-plugin-support": { + "bun": "./src/plugin/runtime-plugin-support.bun.ts", + "node": "./src/plugin/runtime-plugin-support.node.ts", + "default": "./src/plugin/runtime-plugin-support.node.ts" } }, "dependencies": { diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 6dc8df9729..6b9d019db1 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -16,7 +16,7 @@ import { fileURLToPath, pathToFileURL } from "url" import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context" import { createStore, produce, reconcile as reconcileStore } from "solid-js/store" import { useRenderer } from "@opentui/solid" -import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure" +import "#runtime-plugin-support" import { useConfig } from "../config" import { useClient } from "../context/client" import { useData } from "../context/data" @@ -37,8 +37,6 @@ import { abbreviateHome } from "../util/path-format" import { builtins } from "./builtins" import { discoverTuiPlugins } from "./discovery" -ensureRuntimePluginSupport() - export interface PackageResolver { readonly resolve: (spec: string) => Promise } diff --git a/packages/tui/src/plugin/runtime-plugin-support.bun.ts b/packages/tui/src/plugin/runtime-plugin-support.bun.ts new file mode 100644 index 0000000000..2f0a59f4c2 --- /dev/null +++ b/packages/tui/src/plugin/runtime-plugin-support.bun.ts @@ -0,0 +1,3 @@ +import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure" + +ensureRuntimePluginSupport() diff --git a/packages/tui/src/plugin/runtime-plugin-support.node.ts b/packages/tui/src/plugin/runtime-plugin-support.node.ts new file mode 100644 index 0000000000..57380e6385 --- /dev/null +++ b/packages/tui/src/plugin/runtime-plugin-support.node.ts @@ -0,0 +1 @@ +// OpenTUI's runtime plugin transform uses Bun's plugin API. Node loads precompiled plugins without it. From 014908a8d7a02774b0455899dd52d26f4d968aaf Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 13:05:48 -0400 Subject: [PATCH 32/51] feat(tui): reload config file changes --- packages/tui/src/config/index.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index faebe44f40..09ed747b09 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -2,8 +2,10 @@ export * as Config from "." import { createBindingLookup } from "@opentui/keymap/extras" import { Schema } from "effect" -import { createContext, type JSX, useContext } from "solid-js" +import { createContext, onCleanup, type JSX, useContext } from "solid-js" import { createStore, reconcile } from "solid-js/store" +import { watch } from "fs" +import path from "path" import { TuiKeybind } from "./keybind" export interface Interface { @@ -238,12 +240,23 @@ export function ConfigProvider(props: { }) { const [config, setConfig] = createStore(props.config) const host = props.service + const apply = (info: Info) => setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true }))) const update = async (update: (draft: any) => void) => { if (!host) throw new Error("Config updates are not available") const info = await host.update(update) - setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true }))) + apply(info) return info } + let reload = Promise.resolve() + const watcher = host?.path + ? watch(path.dirname(host.path), () => { + reload = reload + .then(() => host.get()) + .then(apply) + .catch(() => {}) + }) + : undefined + onCleanup(() => watcher?.close()) return ( {props.children} ) From b985d2eb8e7449baf0321a0019b9bd7cc85fc015 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Jul 2026 13:50:49 -0400 Subject: [PATCH 33/51] feat(tui): polish session tab animations (#39542) --- packages/tui/src/component/session-tabs.tsx | 182 ++++++++++++----- packages/tui/src/component/tab-pulse.tsx | 187 +++++++++++++----- .../tui/src/context/session-tabs-model.ts | 50 ++++- packages/tui/src/context/session-tabs.tsx | 12 ++ packages/tui/src/context/storage.tsx | 7 +- .../tui/src/feature-plugins/system/scrap.tsx | 8 +- packages/tui/test/component/tab-pulse.test.ts | 14 +- .../test/context/session-tabs-model.test.ts | 52 +++++ 8 files changed, 405 insertions(+), 107 deletions(-) diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 45ed8c6407..db08d10db5 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -1,5 +1,5 @@ import { RGBA, TextAttributes } from "@opentui/core" -import { For, Show, createEffect, createMemo, createSignal } from "solid-js" +import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" import { useConfig } from "../config" import { useSessionTabs } from "../context/session-tabs" @@ -7,20 +7,24 @@ import { useTheme, useThemes } from "../context/theme" import { adaptiveSessionTabLayout, sessionTabComplete, - SESSION_TAB_OVERFLOW_WIDTH, + seedSessionTabMotion, + sessionTabOverflowWidth, type SessionTabUnread, } from "../context/session-tabs-model" -import { createAnimatable, spring } from "../ui/animation" +import { createAnimatable, spring, tween } from "../ui/animation" import { Locale } from "../util/locale" import { stringWidth } from "../util/string-width" import { TabPulse } from "./tab-pulse" import { tint } from "../theme/color" +// A long title fades out over its last cells instead of cutting hard. +const FADE_WIDTH = 4 + type ContextController = ReturnType export type SessionTabsStatus = Omit, "unread"> & { unread: SessionTabUnread | undefined } -export type SessionTabsController = Pick & { +export type SessionTabsController = Pick & { status(sessionID: string): SessionTabsStatus } @@ -32,9 +36,11 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati const config = useConfig().data const animations = () => props.animations ?? config.animations ?? true const [hovered, setHovered] = createSignal() + const [dragging, setDragging] = createSignal() + let strip: { screenX: number } | undefined const hueStep = () => (mode() === "light" ? 800 : 200) const accent = () => theme.hue.accent[hueStep()] - const activeNumber = () => tint(theme.hue.interactive[hueStep()], theme.background.default, 0.25) + const activeNumber = () => theme.hue.interactive[hueStep()] const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35) const activeID = createMemo(tabs.current) const items = tabs.tabs @@ -73,21 +79,38 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati let signature = "" let total = 0 - createEffect(() => { + // createComputed runs before render effects, so seeded widths are visible on the first frame + // of a membership change instead of flashing the final layout. + createComputed(() => { const next = targets() const nextSignature = identity() - const reset = (signature && signature !== nextSignature) || (total && total !== layout().total) + const changed = Boolean(signature) && signature !== nextSignature + const resized = Boolean(total) && total !== layout().total + const previous = signature signature = nextSignature total = layout().total - if (reset) return motion.jump(next) + if (!changed && !resized) return motion.animate(next) + // Identity-stable total changes are terminal resizes and still jump. + if (!changed) return motion.jump(next) + const seeded = seedSessionTabMotion( + previous.split(":"), + layout().tabs.map((tab) => tab.sessionID), + untrack(motion.value), + next, + ) + if (!seeded) return motion.jump(next) + motion.jump(seeded) motion.animate(next) }) + const activeIndex = createMemo(() => layout().tabs.findIndex((tab) => tab.sessionID === activeID())) const visuals = createMemo(() => { const current = signature === identity() && total === layout().total ? motion.value() : targets() const widths = current.widths.map((width) => Math.max(1, Math.round(width))) - const active = layout().tabs.findIndex((tab) => tab.sessionID === activeID()) - if (active !== -1) widths[active]! += layout().total - widths.reduce((sum, width) => sum + width, 0) + const active = activeIndex() + const remainder = layout().total - widths.reduce((sum, width) => sum + width, 0) + // Absorb only rounding slack; membership animations leave a real gap while widths grow into place. + if (active !== -1 && Math.abs(remainder) <= layout().tabs.length) widths[active]! += remainder return new Map( layout().tabs.map((tab, index) => [ tab.sessionID, @@ -100,8 +123,21 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati ) }) + // Map an absolute pointer column to the items index of the visible slot beneath it. + const slotAt = (x: number) => { + if (!strip) return undefined + const stripX = x - strip.screenX + let edge = layout().before > 0 ? sessionTabOverflowWidth(layout().before) : 0 + for (const [index, width] of layout().widths.entries()) { + edge += width + if (stripX < edge) return layout().before + index + } + return layout().before + layout().widths.length - 1 + } + return ( (strip = element)} height={1} flexShrink={0} position="relative" @@ -127,7 +163,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati }} > 0}> - + ‹{layout().before} @@ -138,38 +174,79 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati const width = () => visuals().get(tab.sessionID)?.width ?? 1 const selection = () => visuals().get(tab.sessionID)?.selection ?? Number(selected()) const activity = () => visuals().get(tab.sessionID)?.activity ?? Number(status().complete) - const background = () => { - const base = - hovered() === tab.sessionID && !selected() - ? theme.background.action.primary.hovered - : theme.background.default - return tint(base, theme.raise(theme.background.surface.offset), selection()) + const dragged = () => dragging() === tab.sessionID + const background = createMemo(() => { + const lifted = (hovered() === tab.sessionID || dragged()) && !selected() + const base = lifted ? theme.background.action.primary.hovered : theme.background.default + // A dragged tab lifts to full selected elevation while it is held. + return tint(base, theme.raise(theme.background.surface.offset), dragged() ? 1 : selection()) + }) + const pulseColor = () => tint(background(), theme.text.default, 0.45) + const feedbackColor = () => { + if (status().attention) return theme.text.feedback.warning.default + if (status().unread === "error") return theme.text.feedback.error.default + return undefined } - const pulseBackground = () => background() - const pulseColor = () => tint(pulseBackground(), theme.text.default, 0.45) + const glowColor = () => feedbackColor() ?? accent() + const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined)) const title = () => tab.title ?? "Untitled session" - const availableTitleWidth = () => Math.max(1, width() - 3) + const [outgoingTitle, setOutgoingTitle] = createSignal() + const wipe = createAnimatable({ front: 1 }, { enabled: animations, transition: tween({ duration: 0.3 }) }) + createEffect((previous: string) => { + const next = title() + if (next === previous) return next + setOutgoingTitle(previous) + wipe.jump({ front: 0 }) + wipe.animate({ front: 1 }) + return next + }, title()) + const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1) + // The number cell keeps one trailing space, even for double-digit tabs. + const numberWidth = () => String(tabNumber()).length + 1 + // Hovering reveals the close mark, so the title's right bound shifts left of it. + const availableTitleWidth = () => + Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0)) const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth())) const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle())) - const fadeWidth = () => (hovered() === tab.sessionID ? 6 : 4) - const fadedTitleParts = createMemo(() => visibleTitleParts().slice(-fadeWidth())) + const outgoingTitleParts = createMemo(() => { + const outgoing = outgoingTitle() + if (outgoing === undefined) return undefined + return Locale.graphemes(Locale.takeWidth(outgoing, availableTitleWidth())) + }) + // A new title wipes in from the left over the previous one. + const displayedParts = createMemo(() => { + const front = wipe.value().front + const parts = visibleTitleParts() + const previous = outgoingTitleParts() + if (previous === undefined || front >= 1) return parts + const cut = Math.round(front * Math.max(parts.length, previous.length)) + return [...parts.slice(0, cut), ...previous.slice(cut)] + }) + const fadedTitleParts = createMemo(() => displayedParts().slice(-FADE_WIDTH)) const titleFades = createMemo( - () => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > fadeWidth(), + () => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH, ) const foreground = () => { if (hovered() === tab.sessionID) return theme.text.default return tint(theme.text.subdued, theme.text.default, selection()) } const numberColor = () => { - if (status().attention) return theme.text.feedback.warning.default - if (status().unread === "error") return theme.text.feedback.error.default + const feedback = feedbackColor() + if (feedback) return feedback const base = hovered() === tab.sessionID && !selected() ? foreground() : tint(idleNumber(), activeNumber(), selection()) return tint(base, accent(), activity()) } + const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined) const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6) + // Releasing a drag (or a plain click) selects the tab, matching browser tab strips and + // keeping sloppy clicks indistinguishable from clean ones. + const release = () => { + setDragging(undefined) + tabs.select(tab.sessionID) + } return ( setHovered(tab.sessionID)} onMouseOut={() => setHovered(undefined)} - onMouseUp={() => tabs.select(tab.sessionID)} + onMouseDown={() => setDragging(tab.sessionID)} + onMouseUp={release} + onMouseDrag={(event) => { + const slot = slotAt(event.x) + if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot) + }} + onMouseDragEnd={release} > - - - {items().findIndex((item) => item.sessionID === tab.sessionID) + 1} + + {" "} - - {visibleTitle()} - - } + + {tabNumber()} + + - - {visibleTitleParts().slice(0, -fadeWidth()).join("")} + + {displayedParts().slice(0, -FADE_WIDTH).join("")} {(character, index) => ( )} - - + + { event.stopPropagation() tabs.close(tab.sessionID) @@ -241,8 +327,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati }} 0}> - - {layout().after}› + + {" " + layout().after}› diff --git a/packages/tui/src/component/tab-pulse.tsx b/packages/tui/src/component/tab-pulse.tsx index b06a4f189d..7e994f0b04 100644 --- a/packages/tui/src/component/tab-pulse.tsx +++ b/packages/tui/src/component/tab-pulse.tsx @@ -6,6 +6,7 @@ type TabPulseOptions = RenderableOptions & { active?: boolean complete?: boolean glow?: boolean + breathe?: boolean color?: RGBA glowColor?: RGBA completionColor?: RGBA @@ -20,6 +21,15 @@ const RUN_TAIL = 18 const RUN_FADE_OUT = 500 const COMPLETION_DURATION = 900 const COMPLETION_ATTACK = 0.16 +const COMPLETION_OPACITY = 0.18 +const EDGE_FLASH_DURATION = 500 +const EDGE_FLASH_OPACITY = 0.1 +const GLOW_IGNITION_DURATION = 600 +const GLOW_IGNITION_PEAK = 1.5 +const GLOW_IGNITION_ATTACK = 0.3 +const GLOW_FADE_OUT = 200 +const GLOW_BREATHE_PERIOD = 3_600 +const GLOW_BREATHE_RISE = 0.25 const GLOW_TAIL = 12 const GLOW_OPACITY = 0.16 const DEFAULT_FOREGROUND = RGBA.defaultForeground() @@ -33,10 +43,15 @@ const coast = (value: number) => { if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp)) return (value - ramp / 2) / (1 - ramp) } -export const completionPulseOpacity = (progress: number) => - progress < COMPLETION_ATTACK - ? smootherstep(clamp(progress / COMPLETION_ATTACK)) - : 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK))) +const fadeOut = (progress: number) => 1 - smootherstep(progress) +/** Rise to peak over the attack fraction, then settle to rest over the remainder. */ +const attackDecay = (progress: number, attack: number, peak: number, rest: number) => + progress < attack + ? peak * smootherstep(clamp(progress / attack)) + : peak - (peak - rest) * smootherstep(clamp((progress - attack) / (1 - attack))) +export const completionPulseOpacity = (progress: number) => attackDecay(progress, COMPLETION_ATTACK, 1, 0) +export const glowIgnitionLevel = (progress: number) => + attackDecay(progress, GLOW_IGNITION_ATTACK, GLOW_IGNITION_PEAK, 1) export const unreadGlowIntensity = (index: number, width: number) => { const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2)) return smootherstep(clamp(1 - Math.max(0, index - 1) / tail)) @@ -61,19 +76,64 @@ export function blendTabPulseColor( output.g += (completionColor.g - output.g) * completion output.b += (completionColor.b - output.b) * completion } + +/** A one-shot animation clock: level() follows shape over duration, scaled by the value passed to start. */ +class Envelope { + private clock: number | undefined + private scale = 1 + + constructor( + private duration: number, + private shape: (progress: number) => number, + ) {} + + start(scale = 1) { + if (this.clock !== undefined) return + this.clock = 0 + this.scale = scale + } + + stop() { + this.clock = undefined + } + + advance(delta: number) { + if (this.clock === undefined) return + this.clock += delta + if (this.clock >= this.duration) this.clock = undefined + } + + get active() { + return this.clock !== undefined + } + + level() { + return this.clock === undefined ? 0 : this.scale * this.shape(this.clock / this.duration) + } +} + +// Hoisted so the per-frame liveness check allocates no closure. +const envelopeActive = (envelope: Envelope) => envelope.active + class TabPulseRenderable extends Renderable { private _enabled: boolean private _active: boolean private _complete: boolean private _glow: boolean + private _breathe: boolean private _color: RGBA private _glowColor: RGBA private _completionColor: RGBA private _backgroundColor: RGBA private clock = 0 - private fadeClock: number | undefined - private completionClock: number | undefined + private breatheClock = 0 private completionPending = false + private runFade = new Envelope(RUN_FADE_OUT, fadeOut) + private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity) + private edgeFlash = new Envelope(EDGE_FLASH_DURATION, completionPulseOpacity) + private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel) + private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut) + private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff] private renderColor = RGBA.fromInts(0, 0, 0) constructor(ctx: RenderContext, options: TabPulseOptions = {}) { @@ -84,21 +144,36 @@ class TabPulseRenderable extends Renderable { this._active = active this._complete = options.complete ?? false this._glow = options.glow ?? false + this._breathe = options.breathe ?? false this._color = options.color ?? RGBA.defaultForeground() this._glowColor = options.glowColor ?? this._color this._completionColor = options.completionColor ?? this._color this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground() } + private get breathing() { + return this._enabled && this._glow && this._breathe + } + + /** Resting glow is 1; ignition overshoots on arrival, breathing swells while pending, glowOff decays after. */ + private glowLevel() { + if (!this._glow) return this.glowOff.level() + const base = this.ignition.active ? this.ignition.level() : 1 + if (!this.breathing) return base + return ( + base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD))) + ) + } + set enabled(value: boolean) { if (value === this._enabled) return this._enabled = value if (!value) { - this.fadeClock = undefined - this.completionClock = undefined + for (const envelope of this.envelopes) envelope.stop() this.completionPending = false + this.breatheClock = 0 this.live = false - } else if (this._active) { + } else if (this._active || this.breathing) { this.live = true } this.requestRender() @@ -109,15 +184,16 @@ class TabPulseRenderable extends Renderable { this._active = value if (!this._enabled) return if (value) { - this.fadeClock = undefined - this.completionClock = undefined + this.runFade.stop() + this.completionPulse.stop() this.completionPending = false - this.live = true } else { - this.fadeClock = 0 + this.runFade.start() this.completionPending = true - this.live = true } + // The same neutral edge flash marks both the start and the finish of a run. + this.edgeFlash.start() + this.live = true this.requestRender() } @@ -125,20 +201,38 @@ class TabPulseRenderable extends Renderable { if (value === this._complete) return this._complete = value if (!value) { - this.completionClock = undefined + this.completionPulse.stop() this.completionPending = false } if (value && this.completionPending) { - this.completionClock = 0 this.completionPending = false - this.live = this._enabled + if (this._enabled) { + this.completionPulse.start() + this.live = true + } } this.requestRender() } set glow(value: boolean) { if (value === this._glow) return + if (this._enabled && !value) this.glowOff.start(this.glowLevel()) this._glow = value + this.ignition.stop() + this.breatheClock = 0 + if (this._enabled && value) { + this.glowOff.stop() + this.ignition.start() + this.live = true + } + this.requestRender() + } + + set breathe(value: boolean) { + if (value === this._breathe) return + this._breathe = value + this.breatheClock = 0 + if (this.breathing) this.live = true this.requestRender() } @@ -168,61 +262,52 @@ class TabPulseRenderable extends Renderable { protected override onUpdate(deltaTime: number): void { if (!this._enabled) return - if (this._active || this.fadeClock !== undefined) this.clock += deltaTime - if (this.fadeClock !== undefined) { - this.fadeClock += deltaTime - if (this.fadeClock >= RUN_FADE_OUT) this.fadeClock = undefined - } + if (this._active || this.runFade.active) this.clock += deltaTime + if (this.breathing) this.breatheClock += deltaTime + for (const envelope of this.envelopes) envelope.advance(deltaTime) if (this.completionPending) { if (this._complete) { - this.completionClock = 0 this.completionPending = false - } else if (this.fadeClock === undefined) { + this.completionPulse.start() + } else if (!this.runFade.active) { this.completionPending = false } } - if (this.completionClock !== undefined) { - this.completionClock += deltaTime - if (this.completionClock >= COMPLETION_DURATION) this.completionClock = undefined - } - this.live = this._active || this.fadeClock !== undefined || this.completionClock !== undefined + this.live = this._active || this.breathing || this.envelopes.some(envelopeActive) } protected override renderSelf(buffer: OptimizedBuffer): void { if (!this.visible || this.isDestroyed || this.width <= 0) return - const runningOpacity = !this._enabled - ? 0 - : this._active - ? 1 - : this.fadeClock === undefined - ? 0 - : 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT)) - const completionOpacity = - !this._enabled || this.completionClock === undefined - ? 0 - : completionPulseOpacity(this.completionClock / COMPLETION_DURATION) - if (!this._glow && runningOpacity === 0 && completionOpacity === 0) return + const running = !this._enabled ? 0 : this._active ? 1 : this.runFade.level() + const completion = this.completionPulse.level() * COMPLETION_OPACITY + // The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results. + const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY + const glowLevel = this.glowLevel() + if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) return const progress = (this.clock % RUN_DURATION) / RUN_DURATION const start = -RUN_HEAD const end = this.width - 1 + RUN_TAIL const front = start + coast(progress) * (end - start) const secondFront = start + coast((progress + 0.5) % 1) * (end - start) for (let index = 0; index < this.width; index++) { - const intensity = Math.max( - intensityAt(index, front, RUN_HEAD, RUN_TAIL), - intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL), - ) - const glow = this._glow ? unreadGlowIntensity(index, this.width) * GLOW_OPACITY : 0 - const running = intensity * 0.14 * runningOpacity - const completion = completionOpacity * 0.18 + // Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow. + const sweep = + running === 0 + ? 0 + : Math.max( + intensityAt(index, front, RUN_HEAD, RUN_TAIL), + intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL), + ) * + 0.14 * + running blendTabPulseColor( this.renderColor, this._backgroundColor, this._glowColor, this._color, this._completionColor, - glow, - running, + glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel, + Math.max(sweep, flash), completion, ) buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor) @@ -243,6 +328,7 @@ export function TabPulse(props: { active: boolean complete?: boolean glow?: boolean + breathe?: boolean color: RGBA glowColor?: RGBA completionColor?: RGBA @@ -257,6 +343,7 @@ export function TabPulse(props: { active={props.active} complete={props.complete ?? false} glow={props.glow ?? false} + breathe={props.breathe ?? false} color={props.color} glowColor={props.glowColor ?? props.color} completionColor={props.completionColor ?? props.color} diff --git a/packages/tui/src/context/session-tabs-model.ts b/packages/tui/src/context/session-tabs-model.ts index 0347c8e57b..433a19150b 100644 --- a/packages/tui/src/context/session-tabs-model.ts +++ b/packages/tui/src/context/session-tabs-model.ts @@ -17,7 +17,8 @@ export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: b export const SESSION_TAB_WIDTH = 22 export const SESSION_TAB_MAX_WIDTH = 32 export const SESSION_TAB_MIN_WIDTH = 8 -export const SESSION_TAB_OVERFLOW_WIDTH = 3 +// Overflow markers reserve one gap cell beside the arrow and count, e.g. "‹12 " and " 12›". +export const sessionTabOverflowWidth = (count: number) => String(count).length + 2 export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] { const index = tabs.findIndex((item) => item.sessionID === tab.sessionID) @@ -35,6 +36,15 @@ export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) } } +export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: number): SessionTab[] { + const from = tabs.findIndex((tab) => tab.sessionID === sessionID) + const to = Math.max(0, Math.min(tabs.length - 1, index)) + if (from === -1 || from === to) return tabs + const next = tabs.filter((tab) => tab.sessionID !== sessionID) + next.splice(to, 0, tabs[from]) + return next +} + export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) { if (tabs.length === 0) return const index = tabs.findIndex((tab) => tab.sessionID === active) @@ -67,6 +77,38 @@ export function moveSessionTabHistory( return { history: { ...history, index: target.index }, sessionID: target.sessionID } } +export type SessionTabMotionValues = { + widths: number[] + selections: number[] + activities: number[] +} + +/** + * Seed width motion for a visible-tab membership change: retained tabs keep their current animated + * values and first-seen tabs grow in from zero width. Returns undefined when nothing is retained, + * meaning the window was fully replaced and the strip should jump. + */ +export function seedSessionTabMotion( + previous: readonly string[], + ids: readonly string[], + current: SessionTabMotionValues, + next: SessionTabMotionValues, +): SessionTabMotionValues | undefined { + const positions = ids.map((id) => previous.indexOf(id)) + if (positions.every((position) => position === -1)) return undefined + return { + widths: positions.map((position, index) => + position === -1 ? 0 : (current.widths[position] ?? next.widths[index] ?? 0), + ), + selections: positions.map( + (position, index) => (position === -1 ? next.selections[index] : current.selections[position]) ?? 0, + ), + activities: positions.map( + (position, index) => (position === -1 ? next.activities[index] : current.activities[position]) ?? 0, + ), + } +} + export function adaptiveSessionTabLayout( tabs: readonly SessionTab[], active: string | undefined, @@ -102,8 +144,8 @@ export function adaptiveSessionTabLayout( tabs.length - count, ) const markers = - (nextStart > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) + - (nextStart + count < tabs.length ? SESSION_TAB_OVERFLOW_WIDTH : 0) + (nextStart > 0 ? sessionTabOverflowWidth(nextStart) : 0) + + (nextStart + count < tabs.length ? sessionTabOverflowWidth(tabs.length - nextStart - count) : 0) const nextCount = fit(available - markers) if (nextCount === count || attempts === 0) return { count, start: nextStart } return solve(nextCount, nextStart, attempts - 1) @@ -114,7 +156,7 @@ export function adaptiveSessionTabLayout( const after = tabs.length - solved.start - solved.count const contentWidth = Math.max( 1, - available - (before > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) - (after > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0), + available - (before > 0 ? sessionTabOverflowWidth(before) : 0) - (after > 0 ? sessionTabOverflowWidth(after) : 0), ) const roomy = contentWidth >= SESSION_TAB_WIDTH * visible.length const total = roomy ? Math.min(contentWidth, SESSION_TAB_MAX_WIDTH * visible.length) : contentWidth diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 0533936e4a..9daeb4fd54 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -10,6 +10,7 @@ import { useTuiPaths } from "./runtime" import { closeSessionTab, cycleSessionTab, + moveSessionTab, moveSessionTabHistory, openSessionTab, recordSessionTabHistory, @@ -39,11 +40,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp const config = useConfig().data const paths = useTuiPaths() const enabled = () => config.tabs?.enabled ?? false + // Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of + // mutating in place, which per-row animations and drag state depend on. const [store, updateStore] = useStorage().store("tabs", { initial: { global: empty(), cwd: {}, }, + key: "sessionID", }) const fallback = empty() let history: SessionTabHistory = { entries: [], index: -1 } @@ -177,6 +181,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp } remove(target, true) }, + move(sessionID: string, index: number) { + if (!enabled()) return + const session = root(sessionID) + if (moveSessionTab(state().tabs, session, index) === state().tabs) return + update((draft) => { + draft.tabs = moveSessionTab(draft.tabs, session, index) + }) + }, cycle(direction: 1 | -1) { if (!enabled()) return const tab = cycleSessionTab(state().tabs, current(), direction) diff --git a/packages/tui/src/context/storage.tsx b/packages/tui/src/context/storage.tsx index 40d050f69a..a3e1d4f2b8 100644 --- a/packages/tui/src/context/storage.tsx +++ b/packages/tui/src/context/storage.tsx @@ -8,6 +8,8 @@ import { useTuiApp, useTuiPaths } from "./runtime" type Options = { readonly initial: Value + /** Reconcile key for arrays inside the stored value, preserving item identity across updates. Defaults to "id". */ + readonly key?: string } type Entry = readonly [Store, (mutation: (draft: Value) => void) => Promise] @@ -53,7 +55,8 @@ function createStorage(root: string, channel: string) { } } const [store, setStore] = createStore(load()) - const reload = () => batch(() => setStore(reconcile(load()))) + const merge = (next: Value) => reconcile(next, { key: options.key }) + const reload = () => batch(() => setStore(merge(load()))) const update = (mutation: (draft: Value) => void) => Flock.withLock( file, @@ -62,7 +65,7 @@ function createStorage(root: string, channel: string) { mutation(draft) const next = clone(draft) await writeJsonAtomic(file, next) - batch(() => setStore(reconcile(next))) + batch(() => setStore(merge(next))) }, { dir: locks }, ) diff --git a/packages/tui/src/feature-plugins/system/scrap.tsx b/packages/tui/src/feature-plugins/system/scrap.tsx index 78a483d04c..ba5772eb92 100644 --- a/packages/tui/src/feature-plugins/system/scrap.tsx +++ b/packages/tui/src/feature-plugins/system/scrap.tsx @@ -2,6 +2,7 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { useTerminalDimensions } from "@opentui/solid" import { batch, createSignal } from "solid-js" import { SessionTabs, type SessionTabsController } from "../../component/session-tabs" +import { moveSessionTab, type SessionTab } from "../../context/session-tabs-model" type FixtureStatus = ReturnType @@ -45,7 +46,7 @@ function Scrap(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() const theme = props.context.theme const elevatedTheme = theme.contextual.elevated - const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6)) + const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6)) const [active, setActive] = createSignal("fixture-2") const [animations, setAnimations] = createSignal(true) const [statuses, setStatuses] = createSignal>({ @@ -61,6 +62,9 @@ function Scrap(props: { context: Plugin.Context }) { status(sessionID) { return statuses()[sessionID] ?? EMPTY_STATUS }, + move(sessionID, index) { + setTabs((current) => moveSessionTab(current, sessionID, index)) + }, select(sessionID) { setActive(sessionID) }, @@ -82,7 +86,7 @@ function Scrap(props: { context: Plugin.Context }) { const items = tabs() if (items.length === 0) return const index = items.findIndex((tab) => tab.sessionID === active()) - controller.select(items[(index + direction + items.length) % items.length]!.sessionID) + controller.select(items[(index + direction + items.length) % items.length].sessionID) } const updateStatus = (update: (status: FixtureStatus) => FixtureStatus) => { const sessionID = active() diff --git a/packages/tui/test/component/tab-pulse.test.ts b/packages/tui/test/component/tab-pulse.test.ts index 21561b6e29..7d21611984 100644 --- a/packages/tui/test/component/tab-pulse.test.ts +++ b/packages/tui/test/component/tab-pulse.test.ts @@ -1,6 +1,11 @@ import { expect, test } from "bun:test" import { RGBA } from "@opentui/core" -import { blendTabPulseColor, completionPulseOpacity, unreadGlowIntensity } from "../../src/component/tab-pulse" +import { + blendTabPulseColor, + completionPulseOpacity, + glowIgnitionLevel, + unreadGlowIntensity, +} from "../../src/component/tab-pulse" import { tint } from "../../src/theme/color" test("completion pulse rises quickly and fades over the remaining duration", () => { @@ -11,6 +16,13 @@ test("completion pulse rises quickly and fades over the remaining duration", () expect(completionPulseOpacity(1)).toBe(0) }) +test("glow ignition overshoots the resting level and settles back to it", () => { + expect(glowIgnitionLevel(0)).toBe(0) + expect(glowIgnitionLevel(0.3)).toBeCloseTo(1.5) + expect(glowIgnitionLevel(0.6)).toBeGreaterThan(1) + expect(glowIgnitionLevel(1)).toBe(1) +}) + test("unread glow peaks behind the tab number and fades to the normal background", () => { const intensities = Array.from({ length: 22 }, (_, index) => unreadGlowIntensity(index, 22)) diff --git a/packages/tui/test/context/session-tabs-model.test.ts b/packages/tui/test/context/session-tabs-model.test.ts index 6e93d53b01..c5946f7d35 100644 --- a/packages/tui/test/context/session-tabs-model.test.ts +++ b/packages/tui/test/context/session-tabs-model.test.ts @@ -3,13 +3,65 @@ import { adaptiveSessionTabLayout, closeSessionTab, cycleSessionTab, + moveSessionTab, moveSessionTabHistory, openSessionTab, recordSessionTabHistory, + seedSessionTabMotion, sessionTabComplete, + sessionTabOverflowWidth, } from "../../src/context/session-tabs-model" describe("session tabs", () => { + test("moves a tab to a clamped index and returns the same tabs for no-ops", () => { + const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID })) + expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"]) + expect(moveSessionTab(tabs, "c", -5).map((tab) => tab.sessionID)).toEqual(["c", "a", "b"]) + expect(moveSessionTab(tabs, "b", 99).map((tab) => tab.sessionID)).toEqual(["a", "c", "b"]) + expect(moveSessionTab(tabs, "b", 1)).toBe(tabs) + expect(moveSessionTab(tabs, "missing", 0)).toBe(tabs) + }) + + test("open seeding keeps survivors and grows the new tab from zero", () => { + const seeded = seedSessionTabMotion( + ["a", "b"], + ["a", "b", "c"], + { widths: [35, 35], selections: [1, 0], activities: [0, 1] }, + { widths: [24, 23, 23], selections: [1, 0, 0], activities: [0, 1, 0] }, + ) + expect(seeded).toEqual({ widths: [35, 35, 0], selections: [1, 0, 0], activities: [0, 1, 0] }) + }) + + test("close seeding keeps survivors at their current animated widths", () => { + const seeded = seedSessionTabMotion( + ["a", "b", "c"], + ["a", "c"], + { widths: [24, 23, 23], selections: [1, 0, 0], activities: [0, 0, 1] }, + { widths: [35, 35], selections: [1, 0], activities: [0, 1] }, + ) + expect(seeded).toEqual({ widths: [24, 23], selections: [1, 0], activities: [0, 1] }) + }) + + test("window shifts keep retained tabs and grow revealed ones", () => { + const seeded = seedSessionTabMotion( + ["a", "b", "c"], + ["b", "c", "d"], + { widths: [22, 8, 8], selections: [1, 0, 0], activities: [0, 0, 0] }, + { widths: [8, 8, 22], selections: [0, 0, 1], activities: [0, 0, 0] }, + ) + expect(seeded).toEqual({ widths: [8, 8, 0], selections: [0, 0, 1], activities: [0, 0, 0] }) + }) + + test("fully replaced windows jump instead of seeding", () => { + const values = { widths: [22], selections: [1], activities: [0] } + expect(seedSessionTabMotion(["a"], ["z"], values, values)).toBeUndefined() + }) + + test("overflow markers reserve room for a gap beside their digits", () => { + expect(sessionTabOverflowWidth(5)).toBe(3) + expect(sessionTabOverflowWidth(12)).toBe(4) + }) + test("opens each session once and refreshes its title", () => { const tabs = openSessionTab([{ sessionID: "a", title: "Old" }], { sessionID: "a", title: "New" }) expect(tabs).toEqual([{ sessionID: "a", title: "New" }]) From 5cb633a48e5bdacb56cea3a75cceac8b656560e2 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:16:49 -0500 Subject: [PATCH 34/51] feat(core): support pinned Code Mode tools (#39550) --- packages/core/src/codemode/catalog.ts | 24 +++++++++++++---- packages/core/src/codemode/tool.ts | 21 ++++++++++----- packages/core/test/codemode.test.ts | 2 ++ packages/core/test/codemode/catalog.test.ts | 27 ++++++++++++++++++- .../test/session-runner-tool-registry.test.ts | 2 +- packages/core/test/session-runner.test.ts | 2 +- packages/schema/src/tool.ts | 15 +++++++++-- 7 files changed, 77 insertions(+), 16 deletions(-) diff --git a/packages/core/src/codemode/catalog.ts b/packages/core/src/codemode/catalog.ts index eafc156894..00d1b71e2d 100644 --- a/packages/core/src/codemode/catalog.ts +++ b/packages/core/src/codemode/catalog.ts @@ -6,6 +6,7 @@ export const Entry = Schema.Struct({ path: Schema.String, description: Schema.String, signature: Schema.String, + pinned: Schema.optionalKey(Schema.Boolean), }) export type Entry = typeof Entry.Type @@ -56,26 +57,39 @@ export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET) if (left.path > right.path) return 1 return 0 }) + const ranked = rankListings(listings) + const pinned = new Set( + namespaceEntries + .filter((entry) => entry.pinned) + .map((entry) => listings.find((listing) => listing.path === entry.path)) + .filter((listing) => listing !== undefined), + ) return { name, listings, - selectionOrder: rankListings(listings), - selectedListings: new Set(), + selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)), + selectedListings: pinned, + selectionIndex: 0, } }) const active = new Set(namespaces) - let remaining = budget + let remaining = + budget - + namespaces + .flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing))) + .reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0) while (active.size > 0) { for (const namespace of active) { - const candidate = namespace.selectionOrder[namespace.selectedListings.size] + const candidate = namespace.selectionOrder[namespace.selectionIndex] if (!candidate || candidate.cost > remaining) { active.delete(namespace) continue } namespace.selectedListings.add(candidate.listing) + namespace.selectionIndex += 1 remaining -= candidate.cost - if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace) + if (namespace.selectionIndex === namespace.selectionOrder.length) active.delete(namespace) } } diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index b0e6d7b319..b0a13ac58f 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -138,7 +138,14 @@ export const create = ( } export const catalog = (registrations: ReadonlyMap) => { - return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog() + const pinned = new Set( + Array.from(registrations.values()) + .filter((registration) => registration.options?.pinned === true) + .map(qualifiedName), + ) + return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))) + .catalog() + .map((entry) => ({ ...entry, pinned: pinned.has(entry.path) })) } function runtime( @@ -149,11 +156,7 @@ function runtime( const tools: Record> = {} for (const [name, registration] of registrations) { const child = definition(registration) - const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_") - const path = - registration.options?.namespace === undefined - ? normalized - : `${registration.options.namespace}.${normalized}` + const path = qualifiedName(registration) tools[path] = Tool.make({ description: child.description, input: child.inputSchema, @@ -164,6 +167,12 @@ function runtime( return CodeMode.make({ tools, ...hooks }) } +function qualifiedName(registration: Info) { + const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_") + if (registration.options?.namespace === undefined) return normalized + return `${registration.options.namespace}.${normalized}` +} + // Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact. function displayInput(input: unknown): Record | undefined { if (input === null || input === undefined) return diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts index 2c3776b741..25a5f87941 100644 --- a/packages/core/test/codemode.test.ts +++ b/packages/core/test/codemode.test.ts @@ -16,6 +16,7 @@ describe("CodeMode", () => { description: "Echo text", input: Schema.Struct({ text: Schema.String }), output: Schema.String, + options: { pinned: true }, execute: ({ text }) => Effect.succeed({ output: text }), }), ) @@ -27,6 +28,7 @@ describe("CodeMode", () => { path: "echo", description: "Echo text", signature: "tools.echo(input: {\n text: string,\n}): Promise", + pinned: true, }, ]) }).pipe( diff --git a/packages/core/test/codemode/catalog.test.ts b/packages/core/test/codemode/catalog.test.ts index 1168c7ee67..afa1f06389 100644 --- a/packages/core/test/codemode/catalog.test.ts +++ b/packages/core/test/codemode/catalog.test.ts @@ -2,10 +2,11 @@ import { describe, expect, test } from "bun:test" import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog" import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" -const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({ +const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({ path, description, signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise`, + pinned, }) const lookup = entry( @@ -46,6 +47,30 @@ describe("CodeModeCatalog.summarize", () => { expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true) }) + test("always retains pinned tools beyond the inline budget", () => { + const pinned = [ + entry("alpha.first", "First", undefined, true), + entry("beta.second", "Second", undefined, true), + ] + const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0) + + expect(catalog.shown).toBe(2) + expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([ + "alpha.first", + "beta.second", + ]) + }) + + test("spends the budget remaining after pinned tools on unpinned tools", () => { + const pinned = entry("alpha.pinned", "Pinned", undefined, true) + const unpinned = entry("beta.unpinned", "Unpinned") + const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4) + const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4) + + expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2) + expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1) + }) + test("retains only the rendered portion of inline descriptions", () => { const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)]) expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary") diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 1398a74979..aacbb70069 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -67,7 +67,7 @@ const transform = ( ) => service.transform((draft) => Object.entries(tools).forEach(([name, tool]) => - draft.add({ ...tool, name, options: { ...tool.options, ...options } }), + draft.add({ ...tool, name, options: options ?? tool.options }), ), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index c6b7d7b9c7..6bbf8d90a7 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -231,7 +231,7 @@ const permission = Layer.succeed( const transformTools = (registry: Tool.Interface, tools: Readonly>, options?: Tool.Options) => registry.transform((draft) => Object.entries(tools).forEach(([name, tool]) => - draft.add({ ...tool, name, options: { ...tool.options, ...options } }), + draft.add({ ...tool, name, options: options ?? tool.options }), ), ) const echo = Layer.effectDiscard( diff --git a/packages/schema/src/tool.ts b/packages/schema/src/tool.ts index 1787ebc183..f0f122ba2e 100644 --- a/packages/schema/src/tool.ts +++ b/packages/schema/src/tool.ts @@ -19,12 +19,23 @@ export interface Context { readonly progress: (update: Metadata) => Effect.Effect } -export interface Options { +interface BaseOptions { readonly namespace?: string - readonly codemode?: boolean readonly permission?: string } +export type Options = BaseOptions & + ( + | { + readonly codemode?: true + readonly pinned?: boolean + } + | { + readonly codemode: boolean + readonly pinned?: never + } + ) + export type ValueSchema = | Schema.Codec | (StandardSchemaV1 & StandardJSONSchemaV1) From f7ea2fc34685358aad6cfe92b60c7178a4938cd4 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:21:34 -0500 Subject: [PATCH 35/51] test(cli): update ACP fork expectation (#39554) --- packages/cli/test/acp/service-lifecycle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/acp/service-lifecycle.test.ts b/packages/cli/test/acp/service-lifecycle.test.ts index 35a46a0ee2..2fc39b778c 100644 --- a/packages/cli/test/acp/service-lifecycle.test.ts +++ b/packages/cli/test/acp/service-lifecycle.test.ts @@ -136,7 +136,7 @@ describe("acp service lifecycle", () => { method: "POST", path: "/api/session/ses_loaded/fork", query: {}, - body: {}, + body: { boundary: { type: "through" } }, }) }) From 4f871906bc7084716c950b59f71f69a8125d3848 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Jul 2026 14:45:42 -0400 Subject: [PATCH 36/51] fix(tui): support cd before session creation (#39555) --- packages/tui/src/component/prompt/index.tsx | 21 ++++++++++++++++++--- packages/tui/src/component/prompt/move.tsx | 5 +++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 732359a4a7..2d554a49b1 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -216,19 +216,34 @@ export function Prompt(props: PromptProps) { }) Keymap.createLayer(() => ({ mode: "global", - enabled: props.sessionID !== undefined, commands: [ { id: "session.cd", title: "Change working directory", slash: { name: "cd", arguments: true }, run: async (input) => { - const sessionID = props.sessionID - if (!sessionID) return if (!input?.trim()) { toast.show({ message: "Directory is required", variant: "error" }) return } + const sessionID = props.sessionID + if (!sessionID) { + const value = input.trim() + const expanded = + value === "~" ? paths.home : value.startsWith("~/") ? path.join(paths.home, value.slice(2)) : value + const directory = path.resolve( + currentLocation.current?.directory ?? data.location.default().directory, + expanded, + ) + const location = await client.api.location.get({ location: { directory } }).catch((error) => { + toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" }) + return undefined + }) + if (!location) return + move.setDirectory(location.directory, location.directory !== location.project.directory) + currentLocation.set(location) + return + } await client.api.session .move({ sessionID, directory: input }) .catch((error) => diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index 7b99d80167..e7d420cd1b 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -158,6 +158,10 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess setCreating(false) } + function setDirectory(directory: string, subdirectory: boolean) { + setDestination({ type: "directory", directory, subdirectory }) + } + createEffect(() => { if (!creating()) { setCreatingDots(3) @@ -176,6 +180,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess pending, pendingNew, progress, + setDirectory, startSubmit, } } From c8dca936b146921e68a4d2f98b2e8c8963856d9a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:11:52 -0500 Subject: [PATCH 37/51] feat(plugin): add shell.create.before hook (#39547) --- packages/core/src/plugin/hooks.ts | 2 + packages/core/src/plugin/host.ts | 3 ++ packages/core/src/plugin/promise.ts | 4 ++ packages/core/src/shell.ts | 52 ++++++++++++------- packages/core/src/tool/plugin/shell.ts | 68 ++++++++++++++----------- packages/core/test/plugin-hooks.test.ts | 21 ++++++++ packages/core/test/plugin/host.ts | 3 ++ packages/plugin/src/effect/plugin.ts | 2 + packages/plugin/src/effect/shell.ts | 17 +++++++ packages/plugin/src/promise/plugin.ts | 2 + packages/plugin/src/promise/shell.ts | 17 +++++++ 11 files changed, 142 insertions(+), 49 deletions(-) create mode 100644 packages/plugin/src/effect/shell.ts create mode 100644 packages/plugin/src/promise/shell.ts diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts index 50c019e6fb..1f02bbed97 100644 --- a/packages/core/src/plugin/hooks.ts +++ b/packages/core/src/plugin/hooks.ts @@ -2,6 +2,7 @@ export * as PluginHooks from "./hooks" import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk" import type { SessionHooks } from "@opencode-ai/plugin/effect/session" +import type { ShellHooks } from "@opencode-ai/plugin/effect/shell" import type { ToolHooks } from "@opencode-ai/plugin/effect/tool" import { Context, Effect, Layer, Scope } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" @@ -10,6 +11,7 @@ import { State } from "../state" export interface Domains { readonly aisdk: AISDKHooks readonly session: SessionHooks + readonly shell: ShellHooks readonly tool: ToolHooks } diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index b0b11d2169..6d2aaec6a2 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -296,6 +296,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p }) }), }, + shell: { + hook: (name, callback) => hooks.register("shell", name, callback), + }, tool: { transform: (callback) => tools diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 21380800af..651bc7c8ba 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -326,6 +326,10 @@ export function fromPromise(plugin: Plugin) { ), interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })), }, + shell: { + hook: (name, callback) => + register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), + }, } const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2))) diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index f436683aa7..16318b55a7 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -12,6 +12,8 @@ import { Bus } from "./bus" import { Location } from "./location" import { Global } from "@opencode-ai/util/global" import { ShellSelect } from "./shell/select" +import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell" +import { PluginHooks } from "./plugin/hooks" export class NotFoundError extends Schema.TaggedErrorClass()("Shell.NotFoundError", { id: Shell.ID, @@ -45,7 +47,10 @@ type Active = { */ export interface Interface { readonly name: () => Effect.Effect - readonly create: (input: Shell.CreateInput) => Effect.Effect + readonly create: ( + input: Shell.CreateInput, + before?: (input: ShellCreateBefore) => Effect.Effect, + ) => Effect.Effect // Currently running commands only; exited shells are retained for get/output but excluded here. readonly list: () => Effect.Effect readonly get: (id: Shell.ID) => Effect.Effect @@ -68,6 +73,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( const config = yield* Config.Service const global = yield* Global.Service const appProcess = yield* AppProcess.Service + const hooks = yield* PluginHooks.Service const context = yield* Effect.context() const runFork = Effect.runForkWith(context) const sessions = new Map() @@ -172,24 +178,34 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( } }) - const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) { + const create = Effect.fn("Shell.create")(function* ( + input: Shell.CreateInput, + before?: (input: ShellCreateBefore) => Effect.Effect, + ) { + const invocation: ShellCreateBefore = { + command: input.command, + cwd: input.cwd ?? location.directory, + timeout: input.timeout, + shell: yield* resolve(), + env: { + ...process.env, + TERM: "xterm-256color", + OPENCODE_TERMINAL: "1", + }, + } + yield* hooks.trigger("shell", "create.before", invocation) + if (before) yield* before(invocation) + const id = Shell.ID.ascending() - const cwd = input.cwd ?? location.directory - const shell = yield* resolve() - const args = ShellSelect.args(shell, input.command) + const args = ShellSelect.args(invocation.shell, invocation.command) const file = path.join(outputDir, `${id}.out`) - const env = { - ...process.env, - TERM: "xterm-256color", - OPENCODE_TERMINAL: "1", - } as Record const info: Info = { id, status: "running", - command: input.command, - cwd, - shell, + command: invocation.command, + cwd: invocation.cwd, + shell: invocation.shell, file, metadata: input.metadata ?? {}, time: { started: Date.now() }, @@ -203,9 +219,9 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( Effect.scoped( Effect.gen(function* () { const handle = yield* appProcess.spawn( - ChildProcess.make(shell, args, { - cwd, - env, + ChildProcess.make(invocation.shell, args, { + cwd: invocation.cwd, + env: invocation.env, stdin: "ignore", detached: process.platform !== "win32", forceKillAfter: Duration.seconds(3), @@ -297,7 +313,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( ) }) - yield* session.timeout(input.timeout) + yield* session.timeout(invocation.timeout) runFork( handle.exitCode.pipe( @@ -327,7 +343,7 @@ export function configured(options?: ShellSelect.Options) { return makeLocationNode({ service: Service, layer: layer(options), - deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node], + deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node], }) } diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 84723863ac..b663140688 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -146,34 +146,40 @@ export const Plugin = { messageID: context.messageID, callID: context.callID, } - const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" }) - const external = target.externalDirectory - if (external) - yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), - sessionID: context.sessionID, - agent: context.agent, - source, - }) - yield* permission.assert({ - action: name, - resources: [input.command], - save: [input.command], - sessionID: context.sessionID, - agent: context.agent, - source, - }) - - if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") - return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) - const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS) - const info = yield* shell.create({ - command: input.command, - cwd: target.canonical, - timeout, - metadata: { sessionID: context.sessionID }, - }) + let finalTimeout = timeout + const info = yield* shell.create( + { + command: input.command, + cwd: input.workdir, + timeout, + metadata: { sessionID: context.sessionID }, + }, + (invocation) => + Effect.gen(function* () { + const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" }) + invocation.cwd = target.canonical + finalTimeout = invocation.timeout + const external = target.externalDirectory + if (external) + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + yield* permission.assert({ + action: name, + resources: [invocation.command], + save: [invocation.command], + sessionID: context.sessionID, + agent: context.agent, + source, + }) + if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") + return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) + }), + ) yield* context.progress({ shellID: info.id }) const captureShell = Effect.fn("ShellTool.captureShell")(function* () { @@ -198,7 +204,7 @@ export const Plugin = { if (final.status === "timeout") { return { ...(final.exit !== undefined ? { exit: final.exit } : {}), - output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, + output: `Command exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`, truncated: false, timeout: true, status: "completed" as const, @@ -223,14 +229,14 @@ export const Plugin = { const job = yield* runtime.job.start({ id: context.callID, type: name, - title: input.command, + title: info.command, metadata: { sessionID: context.sessionID, shellID: info.id }, run, }) if (input.background === true) { yield* runtime.job.background(job.id) - yield* notifyWhenDone(context.sessionID, context.callID, input.command) + yield* notifyWhenDone(context.sessionID, context.callID, info.command) return { output: BACKGROUND_STARTED, shellID: info.id, @@ -244,7 +250,7 @@ export const Plugin = { ) if (result?.type === "backgrounded") { yield* shell.timeout(info.id, 0) - yield* notifyWhenDone(context.sessionID, context.callID, input.command) + yield* notifyWhenDone(context.sessionID, context.callID, info.command) return { output: BACKGROUND_STARTED, shellID: info.id, diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts index 4fdf9a9c54..3b489ecca8 100644 --- a/packages/core/test/plugin-hooks.test.ts +++ b/packages/core/test/plugin-hooks.test.ts @@ -42,4 +42,25 @@ describe("PluginHooks", () => { expect(event.messages).toEqual([Message.user("changed")]) }), ) + + it.effect("mutates shell creation input", () => + Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + yield* hooks.register("shell", "create.before", (event) => + Effect.sync(() => { + event.command = "echo changed" + }), + ) + const event = { + command: "echo original", + cwd: "/tmp", + timeout: 0, + shell: "/bin/sh", + env: {}, + } + + expect(yield* hooks.trigger("shell", "create.before", event)).toBe(event) + expect(event.command).toBe("echo changed") + }), + ) }) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 614ce2b323..956c5353d2 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -86,6 +86,9 @@ export function host(overrides: Overrides = {}): Plugin.Context { transform: () => Effect.die("unused skill.transform"), reload: () => Effect.die("unused skill.reload"), }, + shell: overrides.shell ?? { + hook: () => Effect.die("unused shell.hook"), + }, tool: overrides.tool ?? { transform: () => Effect.die("unused tool.transform"), hook: () => Effect.die("unused tool.hook"), diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index 127e6a70b5..4e9d2399e4 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -10,6 +10,7 @@ import type { EventDomain } from "./event.js" import type { IntegrationDomain } from "./integration.js" import type { ReferenceDomain } from "./reference.js" import type { SessionDomain } from "./session.js" +import type { ShellDomain } from "./shell.js" import type { SkillDomain } from "./skill.js" import type { ToolDomain } from "./tool.js" import type { WebSearchDomain } from "./websearch.js" @@ -26,6 +27,7 @@ export interface Context { readonly plugin: PluginApi readonly reference: ReferenceDomain readonly session: SessionDomain + readonly shell: ShellDomain readonly skill: SkillDomain readonly tool: ToolDomain readonly websearch: WebSearchDomain diff --git a/packages/plugin/src/effect/shell.ts b/packages/plugin/src/effect/shell.ts new file mode 100644 index 0000000000..d0e6ae71ed --- /dev/null +++ b/packages/plugin/src/effect/shell.ts @@ -0,0 +1,17 @@ +import type { Hooks } from "./registration.js" + +export interface ShellCreateBefore { + command: string + cwd: string + timeout: number + shell: string + env: Record +} + +export interface ShellHooks { + readonly "create.before": ShellCreateBefore +} + +export interface ShellDomain { + readonly hook: Hooks +} diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index 35225b076c..5fb97a9cb8 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -9,6 +9,7 @@ import type { EventDomain } from "./event.js" import type { IntegrationDomain } from "./integration.js" import type { ReferenceDomain } from "./reference.js" import type { SessionDomain } from "./session.js" +import type { ShellDomain } from "./shell.js" import type { SkillDomain } from "./skill.js" import type { ToolDomain } from "./tool.js" import type { WebSearchDomain } from "./websearch.js" @@ -25,6 +26,7 @@ export interface Context { readonly plugin: PluginApi readonly reference: ReferenceDomain readonly session: SessionDomain + readonly shell: ShellDomain readonly skill: SkillDomain readonly tool: ToolDomain readonly websearch: WebSearchDomain diff --git a/packages/plugin/src/promise/shell.ts b/packages/plugin/src/promise/shell.ts new file mode 100644 index 0000000000..d0e6ae71ed --- /dev/null +++ b/packages/plugin/src/promise/shell.ts @@ -0,0 +1,17 @@ +import type { Hooks } from "./registration.js" + +export interface ShellCreateBefore { + command: string + cwd: string + timeout: number + shell: string + env: Record +} + +export interface ShellHooks { + readonly "create.before": ShellCreateBefore +} + +export interface ShellDomain { + readonly hook: Hooks +} From 464649e67eb8318b49ecfe69e340e8147dc3b3ab Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 29 Jul 2026 15:20:39 -0400 Subject: [PATCH 38/51] feat(tui): batch event delivery (#39551) --- packages/tui/src/context/client.tsx | 24 +- ...lient-connection-characterization.test.tsx | 634 ------------------ 2 files changed, 21 insertions(+), 637 deletions(-) delete mode 100644 packages/tui/test/cli/tui/client-connection-characterization.test.tsx diff --git a/packages/tui/src/context/client.tsx b/packages/tui/src/context/client.tsx index a417d70be0..0aa48240f3 100644 --- a/packages/tui/src/context/client.tsx +++ b/packages/tui/src/context/client.tsx @@ -1,6 +1,6 @@ import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import { createGlobalEmitter } from "@solid-primitives/event-bus" -import { onCleanup, onMount } from "solid-js" +import { batch, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { errorMessage } from "../util/error" import { createSimpleContext } from "./helper" @@ -25,6 +25,7 @@ type ManagedService = { type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract } const connectTimeout = 2_000 const connectionHistoryLimit = 50 +const eventFlushInterval = 10 export const { use: useClient, provider: ClientProvider } = createSimpleContext({ name: "Client", @@ -34,6 +35,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( const history: ClientConnectionEvent[] = [] let api = props.api const events = createGlobalEmitter() + let pending: OpenCodeEvent[] = [] + let flushTimer: ReturnType | undefined const [connection, setConnection] = createStore<{ status: ClientConnectionStatus attempt: number @@ -49,6 +52,19 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( if (history.length > connectionHistoryLimit) history.shift() } + function flushEvents() { + flushTimer = undefined + const queued = pending + pending = [] + batch(() => queued.forEach((event) => events.emit(event.type, event))) + } + + function emit(event: OpenCodeEvent) { + pending.push(event) + if (flushTimer) return + flushTimer = setTimeout(flushEvents, eventFlushInterval) + } + async function connect(signal: AbortSignal, attempt: number) { let connectedAt: number | undefined @@ -80,7 +96,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( record("connected", attempt) connectedAt = Date.now() log.info("event stream connected") - events.emit(first.value.type, first.value) + emit(first.value) setConnection({ status: "connected", attempt: 0, error: undefined }) // Forward events until the stream closes or this connection is cancelled. @@ -97,7 +113,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( seq: event.value.durable.seq, }) - events.emit(event.value.type, event.value) + emit(event.value) } return { error: undefined, connectedAt } @@ -154,6 +170,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( onCleanup(() => { abort.abort() stream?.abort() + if (flushTimer) clearTimeout(flushTimer) + pending = [] events.clear() }) diff --git a/packages/tui/test/cli/tui/client-connection-characterization.test.tsx b/packages/tui/test/cli/tui/client-connection-characterization.test.tsx deleted file mode 100644 index 5ab7315f0e..0000000000 --- a/packages/tui/test/cli/tui/client-connection-characterization.test.tsx +++ /dev/null @@ -1,634 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { afterAll, describe, expect, test } from "bun:test" -import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" -import { testRender } from "@opentui/solid" -import { onMount } from "solid-js" -import type { LogLevel, LogSink } from "../../../src/context/log" -import { createApi, createFetch } from "../../fixture/tui-client" - -const packageRoot = process.env.OPENCODE_TUI_ROOT -const contextModule = packageRoot - ? await import(`${packageRoot}/src/context/client.tsx`) - : await import("../../../src/context/client") -const environmentModule = packageRoot - ? await import(`${packageRoot}/test/fixture/tui-environment.tsx`) - : await import("../../fixture/tui-environment") -const { ClientProvider, useClient } = contextModule as typeof import("../../../src/context/client") -const { TestTuiContexts } = environmentModule as typeof import("../../fixture/tui-environment") - -type Client = ReturnType -type Service = { - reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }> - restart: () => Promise -} -type Observation = { - scenario: string - value: unknown -} - -const observations: Observation[] = [] -const connected = { id: "evt_connected", type: "server.connected", data: {} } as OpenCodeEvent - -afterAll(async () => { - const output = process.env.CLIENT_BEHAVIOR_OUTPUT - if (output) await Bun.write(output, `${JSON.stringify(observations, null, 2)}\n`) -}) - -function observe(scenario: string, value: unknown) { - observations.push({ scenario, value }) -} - -function normalizeError(error: unknown) { - if (error instanceof Error) return `${error.name}:${error.message}` - return String(error) -} - -function history(client: Client) { - return client.connection.internal.history().map((event) => ({ - status: event.data.status, - attempt: event.data.attempt, - error: event.data.error, - })) -} - -async function waitFor(check: () => boolean, timeout = 3_000) { - const started = Date.now() - while (!check()) { - if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(5) - } -} - -function event(type: "vcs" | "update" | "rename", suffix: string): OpenCodeEvent { - if (type === "vcs") { - return { - id: `evt_vcs_${suffix}`, - created: 1, - type: "vcs.branch.updated", - location: { directory: "/tmp/project" }, - data: { branch: suffix }, - } - } - if (type === "update") { - return { - id: `evt_update_${suffix}`, - created: 2, - type: "installation.update-available", - data: { version: suffix }, - } - } - return { - id: `evt_rename_${suffix}`, - created: 3, - type: "session.renamed", - durable: { aggregateID: "ses_test", seq: 1, version: 1 }, - location: { directory: "/tmp/project" }, - data: { sessionID: "ses_test", title: suffix }, - } -} - -function createStream(options?: { first?: OpenCodeEvent; closeBeforeHandshake?: boolean }) { - const encoder = new TextEncoder() - const controllers = new Set>() - const requests: Request[] = [] - const aborts: string[] = [] - let cancellations = 0 - - function response(request: Request) { - requests.push(request) - request.signal.addEventListener("abort", () => aborts.push(normalizeError(request.signal.reason)), { once: true }) - - let current: ReadableStreamDefaultController | undefined - return new Response( - new ReadableStream({ - start(controller) { - current = controller - controllers.add(controller) - if (options?.closeBeforeHandshake) { - controllers.delete(controller) - controller.close() - return - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(options?.first ?? connected)}\n\n`)) - }, - cancel() { - cancellations += 1 - if (current) controllers.delete(current) - }, - }), - { headers: { "content-type": "text/event-stream" } }, - ) - } - - return { - response, - emit(value: OpenCodeEvent) { - const chunk = encoder.encode(`data: ${JSON.stringify(value)}\n\n`) - for (const controller of controllers) controller.enqueue(chunk) - }, - raw(value: string) { - const chunk = encoder.encode(value) - for (const controller of controllers) controller.enqueue(chunk) - }, - close() { - for (const controller of [...controllers]) { - controllers.delete(controller) - controller.close() - } - }, - fail(message: string) { - for (const controller of [...controllers]) { - controllers.delete(controller) - controller.error(new Error(message)) - } - }, - snapshot() { - return { - requests: requests.length, - requestAborted: requests.map((request) => request.signal.aborted), - aborts, - cancellations, - active: controllers.size, - } - }, - } -} - -function apiFor(stream: ReturnType) { - return createApi( - createFetch((url, request) => { - if (url.pathname === "/api/event") return stream.response(request) - }).fetch, - ) -} - -async function mount(input: { - api: OpenCodeClient - service?: Service - throwOn?: OpenCodeEvent["type"] -}) { - const seen: Array<{ type: string; status: string }> = [] - const typed: string[] = [] - const logs: Array<{ level: LogLevel; message: string; tags: Record }> = [] - let initialStatus = "" - let client!: Client - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - const log: LogSink = (level, message, tags) => { - logs.push({ level, message, tags: { ...tags } }) - } - - const app = await testRender(() => ( - - - { - client = value - initialStatus = value.connection.status() - ready() - }} - onEvent={(value) => { - seen.push({ type: value.type, status: client.connection.status() }) - if (value.type === input.throwOn) throw new Error(`listener failed for ${value.type}`) - }} - onBranch={(branch) => typed.push(branch)} - /> - - - )) - await mounted - - return { app, client, initialStatus, seen, typed, logs } -} - -function Probe(props: { - onReady: (client: Client) => void - onEvent: (event: OpenCodeEvent) => void - onBranch: (branch: string) => void -}) { - const client = useClient() - onMount(() => { - client.event.listen(({ details }) => props.onEvent(details)) - client.event.on("vcs.branch.updated", (value) => props.onBranch(value.data.branch ?? "")) - props.onReady(client) - }) - return -} - -describe("ClientProvider connection characterization", () => { - test("records handshake ordering, event delivery, logging, and active-stream cleanup", async () => { - const stream = createStream() - const setup = await mount({ api: apiFor(stream) }) - - await waitFor(() => setup.client.connection.status() === "connected") - stream.emit(event("vcs", "main")) - stream.emit(event("rename", "renamed")) - stream.emit(event("update", "2.0.0")) - await waitFor(() => setup.seen.length === 4) - - observe("healthy.connected", { - initialStatus: setup.initialStatus, - finalStatus: setup.client.connection.status(), - seen: setup.seen, - typed: setup.typed, - logs: setup.logs, - history: history(setup.client), - stream: stream.snapshot(), - }) - - setup.app.renderer.destroy() - await waitFor(() => stream.snapshot().requestAborted[0] === true) - await Bun.sleep(20) - - observe("healthy.cleanup", { - history: history(setup.client), - stream: stream.snapshot(), - }) - - expect(setup.seen.map((item) => item.type)).toEqual([ - "server.connected", - "vcs.branch.updated", - "session.renamed", - "installation.update-available", - ]) - expect(setup.seen.map((item) => item.status)).toEqual(["connecting", "connected", "connected", "connected"]) - expect(setup.logs.filter((item) => item.message === "event")).toHaveLength(1) - }) - - test("records an invalid first event", async () => { - const stream = createStream({ first: event("vcs", "invalid-handshake") }) - const setup = await mount({ api: apiFor(stream) }) - - await waitFor(() => setup.client.connection.status() === "reconnecting") - observe("handshake.invalid", { - status: setup.client.connection.status(), - error: setup.client.connection.error(), - seen: setup.seen, - history: history(setup.client), - stream: stream.snapshot(), - }) - - setup.app.renderer.destroy() - expect(setup.client.connection.error()).toBe("Event stream did not start with server.connected") - }) - - test("records EOF before the handshake", async () => { - const stream = createStream({ closeBeforeHandshake: true }) - const setup = await mount({ api: apiFor(stream) }) - - await waitFor(() => setup.client.connection.status() === "reconnecting") - observe("handshake.eof", { - status: setup.client.connection.status(), - error: setup.client.connection.error(), - seen: setup.seen, - history: history(setup.client), - stream: stream.snapshot(), - }) - - setup.app.renderer.destroy() - expect(setup.client.connection.error()).toBe("Event stream disconnected") - }) - - test("records a fetch failure before the handshake", async () => { - const calls = createFetch((url) => { - if (url.pathname === "/api/event") throw new Error("network unavailable") - return undefined - }) - const setup = await mount({ api: createApi(calls.fetch) }) - - await waitFor(() => setup.client.connection.status() === "reconnecting") - observe("handshake.fetch-error", { - status: setup.client.connection.status(), - error: setup.client.connection.error(), - seen: setup.seen, - history: history(setup.client), - logs: setup.logs, - }) - - setup.app.renderer.destroy() - expect(setup.client.connection.error()).toBe("Transport") - }) - - test("records the initial connection timeout and request cancellation", async () => { - const requests: Request[] = [] - const calls = createFetch((url, request) => { - if (url.pathname !== "/api/event") return - requests.push(request) - return new Promise((_, reject) => { - request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true }) - }) - }) - const setup = await mount({ api: createApi(calls.fetch) }) - - await waitFor(() => setup.client.connection.status() === "reconnecting", 3_000) - observe("handshake.timeout", { - status: setup.client.connection.status(), - error: setup.client.connection.error(), - requestCount: requests.length, - requestAborted: requests.map((request) => request.signal.aborted), - abortReasons: requests.map((request) => normalizeError(request.signal.reason)), - history: history(setup.client), - }) - - setup.app.renderer.destroy() - expect(setup.client.connection.error()).toBe("Transport") - }) - - test("records static transport reconnection after a connected stream closes", async () => { - const stream = createStream() - const setup = await mount({ api: apiFor(stream) }) - - await waitFor(() => setup.client.connection.status() === "connected") - stream.close() - await waitFor(() => stream.snapshot().requests === 2, 2_000) - await waitFor(() => setup.client.connection.status() === "connected") - - observe("reconnect.static", { - status: setup.client.connection.status(), - seen: setup.seen, - history: history(setup.client), - stream: stream.snapshot(), - logs: setup.logs.filter((item) => item.message !== "event"), - }) - - setup.app.renderer.destroy() - expect(setup.seen.map((item) => item.type)).toEqual(["server.connected", "server.connected"]) - }) - - test("records immediate managed-service replacement", async () => { - const initial = createStream() - const replacement = createStream() - const replacementApi = apiFor(replacement) - const reconnectSignals: boolean[] = [] - const service: Service = { - reconnect(signal) { - reconnectSignals.push(signal.aborted) - return Promise.resolve({ api: replacementApi }) - }, - restart: () => Promise.resolve(), - } - const setup = await mount({ api: apiFor(initial), service }) - - await waitFor(() => setup.client.connection.status() === "connected") - initial.close() - await waitFor(() => replacement.snapshot().requests === 1) - await waitFor(() => setup.client.connection.status() === "connected") - replacement.emit(event("vcs", "replacement")) - await waitFor(() => setup.typed.includes("replacement")) - - observe("reconnect.managed-replacement", { - status: setup.client.connection.status(), - apiReplaced: setup.client.api === replacementApi, - reconnectSignals, - seen: setup.seen, - typed: setup.typed, - history: history(setup.client), - initial: initial.snapshot(), - replacement: replacement.snapshot(), - }) - - setup.app.renderer.destroy() - expect(setup.client.api).toBe(replacementApi) - }) - - test("records managed-service resolution failure and delayed retry", async () => { - const stream = createStream() - let reconnects = 0 - const service: Service = { - reconnect() { - reconnects += 1 - return Promise.reject(new Error("service unavailable")) - }, - restart: () => Promise.resolve(), - } - const setup = await mount({ api: apiFor(stream), service }) - - await waitFor(() => setup.client.connection.status() === "connected") - stream.close() - await waitFor(() => stream.snapshot().requests === 2, 2_000) - await waitFor(() => setup.client.connection.status() === "connected") - - observe("reconnect.managed-failure", { - reconnects, - status: setup.client.connection.status(), - seen: setup.seen, - history: history(setup.client), - stream: stream.snapshot(), - resolutionLogs: setup.logs.filter((item) => item.message === "server resolution failed"), - }) - - setup.app.renderer.destroy() - expect(reconnects).toBe(1) - }) - - test("records cleanup while the initial fetch is pending", async () => { - const requests: Request[] = [] - const aborts: string[] = [] - const calls = createFetch((url, request) => { - if (url.pathname !== "/api/event") return - requests.push(request) - return new Promise((_, reject) => { - request.signal.addEventListener( - "abort", - () => { - aborts.push(normalizeError(request.signal.reason)) - reject(request.signal.reason) - }, - { once: true }, - ) - }) - }) - const setup = await mount({ api: createApi(calls.fetch) }) - - await waitFor(() => requests.length === 1) - setup.app.renderer.destroy() - await waitFor(() => requests[0].signal.aborted) - await Bun.sleep(20) - - observe("cleanup.pending-handshake", { - status: setup.client.connection.status(), - requestAborted: requests[0].signal.aborted, - aborts, - history: history(setup.client), - logs: setup.logs, - }) - - expect(history(setup.client).map((item) => item.status)).toEqual(["connecting"]) - }) - - test("records an event listener failure as a connection failure", async () => { - const stream = createStream() - const setup = await mount({ api: apiFor(stream), throwOn: "vcs.branch.updated" }) - - await waitFor(() => setup.client.connection.status() === "connected") - stream.emit(event("vcs", "throws")) - await waitFor(() => setup.client.connection.status() === "reconnecting") - - observe("listener.failure", { - status: setup.client.connection.status(), - error: setup.client.connection.error(), - seen: setup.seen, - typed: setup.typed, - history: history(setup.client), - stream: stream.snapshot(), - }) - - setup.app.renderer.destroy() - expect(setup.client.connection.error()).toBe("listener failed for vcs.branch.updated") - }) - - test("records stream reader failure after connection", async () => { - const stream = createStream() - const setup = await mount({ api: apiFor(stream) }) - - await waitFor(() => setup.client.connection.status() === "connected") - stream.fail("reader exploded") - await waitFor(() => setup.client.connection.status() === "reconnecting") - - observe("stream.reader-failure", { - status: setup.client.connection.status(), - error: setup.client.connection.error(), - seen: setup.seen, - history: history(setup.client), - stream: stream.snapshot(), - }) - - setup.app.renderer.destroy() - expect(setup.client.connection.error()).toBe("Transport") - }) - - test("records malformed SSE data after connection", async () => { - const stream = createStream() - const setup = await mount({ api: apiFor(stream) }) - - await waitFor(() => setup.client.connection.status() === "connected") - stream.raw("data: not-json\n\n") - await waitFor(() => setup.client.connection.status() === "reconnecting") - - observe("stream.malformed-data", { - status: setup.client.connection.status(), - error: setup.client.connection.error(), - seen: setup.seen, - history: history(setup.client), - stream: stream.snapshot(), - }) - - setup.app.renderer.destroy() - expect(setup.client.connection.error()).toBe("MalformedResponse") - }) - - test("records a server.connected listener failure before connected state publication", async () => { - const stream = createStream() - const setup = await mount({ api: apiFor(stream), throwOn: "server.connected" }) - - await waitFor(() => setup.client.connection.status() === "reconnecting") - observe("listener.connected-failure", { - status: setup.client.connection.status(), - error: setup.client.connection.error(), - seen: setup.seen, - history: history(setup.client), - stream: stream.snapshot(), - }) - - setup.app.renderer.destroy() - expect(history(setup.client).map((item) => item.status)).toEqual(["connecting", "connected", "disconnected"]) - }) - - test("records cleanup during static reconnect backoff", async () => { - const stream = createStream() - const setup = await mount({ api: apiFor(stream) }) - - await waitFor(() => setup.client.connection.status() === "connected") - stream.close() - await waitFor(() => setup.client.connection.status() === "reconnecting") - setup.app.renderer.destroy() - await Bun.sleep(1_050) - - observe("cleanup.reconnect-backoff", { - status: setup.client.connection.status(), - history: history(setup.client), - stream: stream.snapshot(), - }) - - expect(stream.snapshot().requests).toBe(1) - }) - - test("records cleanup during managed-service resolution", async () => { - const stream = createStream() - let resolutionStarted = false - let resolutionAborted = false - const service: Service = { - reconnect(signal) { - resolutionStarted = true - return new Promise((_, reject) => { - signal.addEventListener( - "abort", - () => { - resolutionAborted = true - reject(signal.reason) - }, - { once: true }, - ) - }) - }, - restart: () => Promise.resolve(), - } - const setup = await mount({ api: apiFor(stream), service }) - - await waitFor(() => setup.client.connection.status() === "connected") - stream.close() - await waitFor(() => resolutionStarted) - setup.app.renderer.destroy() - await waitFor(() => resolutionAborted) - await Bun.sleep(20) - - observe("cleanup.service-resolution", { - resolutionStarted, - resolutionAborted, - status: setup.client.connection.status(), - history: history(setup.client), - stream: stream.snapshot(), - logs: setup.logs, - }) - - expect(resolutionAborted).toBe(true) - }) - - test("records attempt reset after a stable connection", async () => { - const streams = [createStream(), createStream(), createStream()] - const apis = streams.map(apiFor) - let reconnects = 0 - const service: Service = { - reconnect() { - const api = apis[Math.min(reconnects + 1, apis.length - 1)] - reconnects += 1 - return Promise.resolve({ api }) - }, - restart: () => Promise.resolve(), - } - const setup = await mount({ api: apis[0], service }) - - await waitFor(() => setup.client.connection.status() === "connected") - streams[0].close() - await waitFor(() => streams[1].snapshot().requests === 1) - streams[1].close() - await waitFor(() => streams[2].snapshot().requests === 1) - await Bun.sleep(1_050) - streams[2].close() - await waitFor(() => reconnects === 3) - - observe("reconnect.stable-reset", { - reconnects, - status: setup.client.connection.status(), - history: history(setup.client), - streams: streams.map((stream) => stream.snapshot()), - }) - - setup.app.renderer.destroy() - expect(history(setup.client).filter((item) => item.status === "disconnected").map((item) => item.attempt)).toEqual([ - 1, 2, 1, - ]) - }) -}) From 210be4b749206de1a6cccba245484930311a9f48 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:31:26 -0500 Subject: [PATCH 39/51] fix(core): preserve shell output on timeout (#39559) --- packages/core/src/tool/plugin/shell.ts | 6 +++--- packages/core/test/tool-shell.test.ts | 23 +++++++++++++++-------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index b663140688..89ad765a83 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -198,20 +198,20 @@ export const Plugin = { const settleShell = Effect.fn("ShellTool.settleShell")(function* () { const final = yield* shell.wait(info.id) + const capture = yield* captureShell() // `exit` is optionalKey in the Output schema; a present-but-undefined key // fails output encoding, so omit it when the process has no exit code. if (final.status === "timeout") { return { ...(final.exit !== undefined ? { exit: final.exit } : {}), - output: `Command exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`, - truncated: false, + output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`, + truncated: capture.truncated, timeout: true, status: "completed" as const, } } - const capture = yield* captureShell() return { ...(final.exit !== undefined ? { exit: final.exit } : {}), output: capture.output, diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 2b540d7538..33f81161ce 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -157,6 +157,9 @@ const mixedOutputCommand = isWindows ? "[Console]::Out.Write('stdout'); Start-Sleep -Milliseconds 50; [Console]::Error.Write('stderr'); Start-Sleep -Milliseconds 100" : "printf stdout; sleep 0.05; printf stderr >&2" const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60" +const timeoutOutputCommand = isWindows + ? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60" + : "printf 'before timeout'; sleep 60" const steadyProgressCommand = isWindows ? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400" : "printf steady; sleep 3.4" @@ -461,14 +464,18 @@ describe("ShellTool", () => { Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { - reset() - return withSession(tmp.path, (registry) => - executeTool(registry, call({ command: idleCommand, timeout: 50 })), - ).pipe( - Effect.andThen((settled) => - Effect.sync(() => { - expect(settled.metadata).toMatchObject({ timeout: true, truncated: false }) - expect(settled.content?.[1]).toMatchObject({ + reset() + return withSession(tmp.path, (registry) => + executeTool(registry, call({ command: timeoutOutputCommand, timeout: 50 })), + ).pipe( + Effect.andThen((settled) => + Effect.sync(() => { + expect(settled.metadata).toMatchObject({ timeout: true, truncated: false }) + expect(settled.content?.[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("before timeout"), + }) + expect(settled.content?.[1]).toMatchObject({ type: "text", text: expect.stringContaining("Command timed out"), }) From 3c259fc552db695c9ea73dc3fc69ecf08bddcb70 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Jul 2026 15:51:15 -0400 Subject: [PATCH 40/51] feat(tui): replace scrap screen with component storybook (#39548) --- packages/tui/src/app.tsx | 13 +- packages/tui/src/component/session-tabs.tsx | 44 ++- packages/tui/src/component/tab-pulse.tsx | 58 ++- packages/tui/src/context/route.tsx | 5 + .../tui/src/feature-plugins/system/scrap.tsx | 186 --------- .../system/storybook/index.tsx | 142 +++++++ .../system/storybook/session-tabs.tsx | 368 ++++++++++++++++++ packages/tui/src/plugin/builtins.ts | 4 +- packages/tui/test/component/tab-pulse.test.ts | 34 +- 9 files changed, 631 insertions(+), 223 deletions(-) delete mode 100644 packages/tui/src/feature-plugins/system/scrap.tsx create mode 100644 packages/tui/src/feature-plugins/system/storybook/index.tsx create mode 100644 packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index d6ba165e42..c4221ee5b7 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -326,8 +326,17 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > tint(background(), theme.text.default, 0.45) + // The edge flash washes toward a brighter stop on the same background-to-text ramp, + // so it reads as a lift of the pulse color rather than a different hue. + const flashColor = () => tint(background(), theme.text.default, 0.65) const feedbackColor = () => { if (status().attention) return theme.text.feedback.warning.default if (status().unread === "error") return theme.text.feedback.error.default @@ -222,7 +225,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati const cut = Math.round(front * Math.max(parts.length, previous.length)) return [...parts.slice(0, cut), ...previous.slice(cut)] }) - const fadedTitleParts = createMemo(() => displayedParts().slice(-FADE_WIDTH)) const titleFades = createMemo( () => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH, ) @@ -230,6 +232,19 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati if (hovered() === tab.sessionID) return theme.text.default return tint(theme.text.subdued, theme.text.default, selection()) } + // Title characters sitting over the glow tinge toward its color, following the same + // spatial falloff as the glow itself; characters beyond the tail stay neutral. + const characterColor = (index: number) => { + const base = foreground() + const color = glows() + ? tint(base, glowColor(), 0.12 * unreadGlowIntensity(1 + numberWidth() + index, width())) + : base + if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color + const position = index - (displayedParts().length - FADE_WIDTH) + return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1))) + } + // The running sweep's level under the number cell, reported by the pulse renderable. + const [sweepLevel, setSweepLevel] = createSignal(0) const numberColor = () => { const feedback = feedbackColor() if (feedback) return feedback @@ -237,7 +252,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati hovered() === tab.sessionID && !selected() ? foreground() : tint(idleNumber(), activeNumber(), selection()) - return tint(base, accent(), activity()) + const color = tint(base, accent(), activity()) + // The number brightens faintly as the running sweep passes beneath it. + return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel()) } const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined) const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6) @@ -271,8 +288,10 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati breathe={status().attention} color={pulseColor()} glowColor={glowColor()} + flashColor={flashColor()} completionColor={accent()} backgroundColor={background()} + onLevel={setSweepLevel} /> @@ -288,22 +307,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati selectable={false} attributes={bold()} > - - {displayedParts().slice(0, -FADE_WIDTH).join("")} - - {(character, index) => ( - - {character} - - )} + + + {(character, index) => {character}} diff --git a/packages/tui/src/component/tab-pulse.tsx b/packages/tui/src/component/tab-pulse.tsx index 7e994f0b04..de2bce5692 100644 --- a/packages/tui/src/component/tab-pulse.tsx +++ b/packages/tui/src/component/tab-pulse.tsx @@ -9,8 +9,11 @@ type TabPulseOptions = RenderableOptions & { breathe?: boolean color?: RGBA glowColor?: RGBA + flashColor?: RGBA completionColor?: RGBA backgroundColor?: RGBA + /** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */ + onLevel?: (level: number) => void } const clamp = (value: number) => Math.max(0, Math.min(1, value)) @@ -19,10 +22,11 @@ const RUN_DURATION = 2_800 const RUN_HEAD = 4 const RUN_TAIL = 18 const RUN_FADE_OUT = 500 -const COMPLETION_DURATION = 900 -const COMPLETION_ATTACK = 0.16 +const COMPLETION_DURATION = 1_200 +const COMPLETION_ATTACK = 0.12 const COMPLETION_OPACITY = 0.18 -const EDGE_FLASH_DURATION = 500 +const EDGE_FLASH_DURATION = 800 +const EDGE_FLASH_ATTACK = 0.1 const EDGE_FLASH_OPACITY = 0.1 const GLOW_IGNITION_DURATION = 600 const GLOW_IGNITION_PEAK = 1.5 @@ -61,9 +65,11 @@ export function blendTabPulseColor( background: RGBA, glowColor: RGBA, runningColor: RGBA, + flashColor: RGBA, completionColor: RGBA, glow: number, running: number, + flash: number, completion: number, ) { output.r = background.r + (glowColor.r - background.r) * glow @@ -72,6 +78,9 @@ export function blendTabPulseColor( output.r += (runningColor.r - output.r) * running output.g += (runningColor.g - output.g) * running output.b += (runningColor.b - output.b) * running + output.r += (flashColor.r - output.r) * flash + output.g += (flashColor.g - output.g) * flash + output.b += (flashColor.b - output.b) * flash output.r += (completionColor.r - output.r) * completion output.g += (completionColor.g - output.g) * completion output.b += (completionColor.b - output.b) * completion @@ -123,6 +132,7 @@ class TabPulseRenderable extends Renderable { private _breathe: boolean private _color: RGBA private _glowColor: RGBA + private _flashColor: RGBA private _completionColor: RGBA private _backgroundColor: RGBA private clock = 0 @@ -130,11 +140,13 @@ class TabPulseRenderable extends Renderable { private completionPending = false private runFade = new Envelope(RUN_FADE_OUT, fadeOut) private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity) - private edgeFlash = new Envelope(EDGE_FLASH_DURATION, completionPulseOpacity) + private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0)) private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel) private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut) private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff] private renderColor = RGBA.fromInts(0, 0, 0) + private _onLevel: ((level: number) => void) | undefined + private lastLevel = 0 constructor(ctx: RenderContext, options: TabPulseOptions = {}) { const enabled = options.enabled ?? true @@ -147,8 +159,21 @@ class TabPulseRenderable extends Renderable { this._breathe = options.breathe ?? false this._color = options.color ?? RGBA.defaultForeground() this._glowColor = options.glowColor ?? this._color + this._flashColor = options.flashColor ?? this._color this._completionColor = options.completionColor ?? this._color this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground() + this._onLevel = options.onLevel + } + + set onLevel(value: ((level: number) => void) | undefined) { + this._onLevel = value + } + + private emitLevel(value: number) { + const quantized = Math.round(value * 32) / 32 + if (quantized === this.lastLevel) return + this.lastLevel = quantized + this._onLevel?.(quantized) } private get breathing() { @@ -248,6 +273,12 @@ class TabPulseRenderable extends Renderable { this.requestRender() } + set flashColor(value: RGBA) { + if (value.equals(this._flashColor)) return + this._flashColor = value + this.requestRender() + } + set completionColor(value: RGBA) { if (value.equals(this._completionColor)) return this._completionColor = value @@ -283,12 +314,21 @@ class TabPulseRenderable extends Renderable { // The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results. const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY const glowLevel = this.glowLevel() - if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) return + if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) { + this.emitLevel(0) + return + } const progress = (this.clock % RUN_DURATION) / RUN_DURATION const start = -RUN_HEAD const end = this.width - 1 + RUN_TAIL const front = start + coast(progress) * (end - start) const secondFront = start + coast((progress + 0.5) % 1) * (end - start) + this.emitLevel( + running === 0 + ? 0 + : Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) * + running, + ) for (let index = 0; index < this.width; index++) { // Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow. const sweep = @@ -305,9 +345,11 @@ class TabPulseRenderable extends Renderable { this._backgroundColor, this._glowColor, this._color, + this._flashColor, this._completionColor, glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel, - Math.max(sweep, flash), + sweep, + flash, completion, ) buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor) @@ -331,8 +373,10 @@ export function TabPulse(props: { breathe?: boolean color: RGBA glowColor?: RGBA + flashColor?: RGBA completionColor?: RGBA backgroundColor: RGBA + onLevel?: (level: number) => void }) { return ( ) } diff --git a/packages/tui/src/context/route.tsx b/packages/tui/src/context/route.tsx index fa8a5a39c1..0c0b7721e8 100644 --- a/packages/tui/src/context/route.tsx +++ b/packages/tui/src/context/route.tsx @@ -55,6 +55,11 @@ function initialRoute(value: unknown): Route | undefined { "name" in value && typeof value.name === "string" ) { + const data = + "data" in value && typeof value.data === "object" && value.data !== null && !Array.isArray(value.data) + ? (value.data as Record) + : undefined + if (data) return { type: "plugin", id: value.id, name: value.name, data } return { type: "plugin", id: value.id, name: value.name } } } diff --git a/packages/tui/src/feature-plugins/system/scrap.tsx b/packages/tui/src/feature-plugins/system/scrap.tsx deleted file mode 100644 index ba5772eb92..0000000000 --- a/packages/tui/src/feature-plugins/system/scrap.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { Plugin } from "@opencode-ai/plugin/tui" -import { useTerminalDimensions } from "@opentui/solid" -import { batch, createSignal } from "solid-js" -import { SessionTabs, type SessionTabsController } from "../../component/session-tabs" -import { moveSessionTab, type SessionTab } from "../../context/session-tabs-model" - -type FixtureStatus = ReturnType - -const FIXTURE_TABS = [ - { sessionID: "fixture-1", title: "Implement session tabs" }, - { sessionID: "fixture-2", title: "Investigate rendering" }, - { sessionID: "fixture-3", title: "A deliberately long session title for truncation" }, - { sessionID: "fixture-4", title: "Fix provider state" }, - { sessionID: "fixture-5", title: "Review animation" }, - { sessionID: "fixture-6", title: "Untitled behavior" }, - { sessionID: "fixture-7", title: "Queue follow-up work" }, - { sessionID: "fixture-8", title: "Check narrow layout" }, - { sessionID: "fixture-9", title: "Profile terminal output" }, - { sessionID: "fixture-10", title: "Handle permission" }, - { sessionID: "fixture-11", title: "Run focused tests" }, - { sessionID: "fixture-12", title: "Prepare review" }, -] - -const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false } - -function Commands(props: { context: Plugin.Context }) { - props.context.keymap.layer(() => ({ - mode: "global", - commands: [ - { - id: "app.scrap", - title: "Open scrap screen", - group: "Debug", - palette: true, - run() { - props.context.ui.router.navigate({ type: "plugin", name: "scrap" }) - props.context.ui.dialog.clear() - }, - }, - ], - })) - return null -} - -function Scrap(props: { context: Plugin.Context }) { - const dimensions = useTerminalDimensions() - const theme = props.context.theme - const elevatedTheme = theme.contextual.elevated - const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6)) - const [active, setActive] = createSignal("fixture-2") - const [animations, setAnimations] = createSignal(true) - const [statuses, setStatuses] = createSignal>({ - "fixture-2": { ...EMPTY_STATUS, busy: true }, - "fixture-3": { ...EMPTY_STATUS, unread: "activity" }, - "fixture-4": { ...EMPTY_STATUS, unread: "error" }, - "fixture-5": { ...EMPTY_STATUS, attention: true }, - "fixture-6": { ...EMPTY_STATUS, busy: true, attention: true }, - }) - const controller = { - tabs, - current: active, - status(sessionID) { - return statuses()[sessionID] ?? EMPTY_STATUS - }, - move(sessionID, index) { - setTabs((current) => moveSessionTab(current, sessionID, index)) - }, - select(sessionID) { - setActive(sessionID) - }, - close(sessionID?: string) { - const target = sessionID ?? active() - if (!target) return - const items = tabs() - const index = items.findIndex((tab) => tab.sessionID === target) - if (index === -1) return - const next = items.filter((tab) => tab.sessionID !== target) - batch(() => { - setTabs(next) - if (active() === target) setActive(next[index]?.sessionID ?? next[index - 1]?.sessionID) - }) - }, - } satisfies SessionTabsController - - const cycle = (direction: 1 | -1) => { - const items = tabs() - if (items.length === 0) return - const index = items.findIndex((tab) => tab.sessionID === active()) - controller.select(items[(index + direction + items.length) % items.length].sessionID) - } - const updateStatus = (update: (status: FixtureStatus) => FixtureStatus) => { - const sessionID = active() - if (!sessionID) return - setStatuses((current) => ({ ...current, [sessionID]: update(current[sessionID] ?? EMPTY_STATUS) })) - } - - props.context.keymap.layer(() => ({ - commands: [ - { - bind: "escape", - title: "Back home", - group: "Scrap", - run() { - props.context.ui.router.navigate({ type: "home" }) - }, - }, - { bind: "h", title: "Previous tab", group: "Scrap", run: () => cycle(-1) }, - { bind: "l", title: "Next tab", group: "Scrap", run: () => cycle(1) }, - { - bind: "t", - title: "Add tab", - group: "Scrap", - run() { - const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID)) - if (next) setTabs((current) => [...current, next]) - }, - }, - { bind: "d", title: "Close tab", group: "Scrap", run: () => controller.close() }, - { - bind: "b", - title: "Toggle busy", - group: "Scrap", - run: () => - updateStatus((status) => - status.busy ? { ...status, busy: false, unread: "activity" } : { ...status, busy: true, unread: undefined }, - ), - }, - { - bind: "u", - title: "Cycle unread", - group: "Scrap", - run: () => - updateStatus((status) => ({ - ...status, - unread: status.unread === undefined ? "activity" : status.unread === "activity" ? "error" : undefined, - })), - }, - { - bind: "a", - title: "Toggle attention", - group: "Scrap", - run: () => updateStatus((status) => ({ ...status, attention: !status.attention })), - }, - { - bind: "m", - title: "Toggle motion", - group: "Scrap", - run: () => setAnimations((enabled) => !enabled), - }, - ], - })) - - return ( - - - - tab playground - - - h/l select | t add | d close | b busy | u unread | a attention | m motion | esc home - - - - - ) -} - -export default Plugin.define({ - id: "opencode.scrap", - setup(context) { - context.ui.router.register({ name: "scrap", render: () => }) - context.ui.slot("app", () => ) - }, -}) diff --git a/packages/tui/src/feature-plugins/system/storybook/index.tsx b/packages/tui/src/feature-plugins/system/storybook/index.tsx new file mode 100644 index 0000000000..32403bd561 --- /dev/null +++ b/packages/tui/src/feature-plugins/system/storybook/index.tsx @@ -0,0 +1,142 @@ +import { Plugin } from "@opencode-ai/plugin/tui" +import { useTerminalDimensions } from "@opentui/solid" +import { createSignal, For, type JSX } from "solid-js" +import { sessionTabsStory } from "./session-tabs" + +/** + * A story is a full-screen, fixture-driven simulation of a real production component. Stories own + * their entire screen (including any footer) and should bind escape back to the storybook index. + */ +export type Story = { + id: string + title: string + render: (context: Plugin.Context) => JSX.Element +} + +const stories: Story[] = [sessionTabsStory] + +function Commands(props: { context: Plugin.Context }) { + props.context.keymap.layer(() => ({ + mode: "global", + commands: [ + { + id: "app.storybook", + title: "Open storybook", + group: "Debug", + palette: true, + run() { + props.context.ui.router.navigate({ type: "plugin", name: "storybook" }) + props.context.ui.dialog.clear() + }, + }, + ...stories.map((story) => ({ + id: `app.storybook.${story.id}`, + title: `Storybook: ${story.title}`, + group: "Debug", + palette: true as const, + run() { + props.context.ui.router.navigate({ type: "plugin", name: "storybook", data: { story: story.id } }) + props.context.ui.dialog.clear() + }, + })), + ], + })) + return null +} + +function StorybookIndex(props: { context: Plugin.Context }) { + const dimensions = useTerminalDimensions() + const theme = props.context.theme + const elevatedTheme = theme.contextual.elevated + const [selected, setSelected] = createSignal(0) + const open = (story: Story) => + props.context.ui.router.navigate({ type: "plugin", name: "storybook", data: { story: story.id } }) + + props.context.keymap.layer(() => ({ + commands: [ + { + bind: "escape", + title: "Back home", + group: "Storybook", + run() { + props.context.ui.router.navigate({ type: "home" }) + }, + }, + { + bind: "up,k", + title: "Previous story", + group: "Storybook", + run: () => setSelected((current) => (current + stories.length - 1) % stories.length), + }, + { + bind: "down,j", + title: "Next story", + group: "Storybook", + run: () => setSelected((current) => (current + 1) % stories.length), + }, + { + bind: "return", + title: "Open story", + group: "Storybook", + run: () => open(stories[selected()]), + }, + ...stories.map((story, index) => ({ + bind: String(index + 1), + title: `Open ${story.title}`, + group: "Storybook", + run: () => open(story), + })), + ], + })) + + return ( + + + storybook + fixture-driven simulations of production components + + + {(story, index) => ( + + {index() === selected() ? "› " : " "} + {index() + 1} {story.title} + + )} + + + + + storybook + + ↑/↓ select | enter open | esc home + + + ) +} + +export default Plugin.define({ + id: "opencode.storybook", + setup(context) { + context.ui.router.register({ + name: "storybook", + render: (input) => { + const story = stories.find((story) => story.id === input.data?.story) + if (story) return story.render(context) + return + }, + }) + context.ui.slot("app", () => ) + }, +}) diff --git a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx new file mode 100644 index 0000000000..81fd627da0 --- /dev/null +++ b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx @@ -0,0 +1,368 @@ +import { Plugin } from "@opencode-ai/plugin/tui" +import { useTerminalDimensions } from "@opentui/solid" +import { batch, createSignal, For, onCleanup } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { SessionTabs, type SessionTabsController } from "../../../component/session-tabs" +import { moveSessionTab } from "../../../context/session-tabs-model" +import type { Story } from "./index" + +type FixtureStatus = ReturnType + +const FIXTURE_TABS = [ + { sessionID: "fixture-1", title: "Implement session tabs" }, + { sessionID: "fixture-2", title: "Investigate rendering" }, + { sessionID: "fixture-3", title: "A deliberately long session title for truncation" }, + { sessionID: "fixture-4", title: "Fix provider state" }, + { sessionID: "fixture-5", title: "Review animation" }, + { sessionID: "fixture-6", title: "Untitled behavior" }, + { sessionID: "fixture-7", title: "Queue follow-up work" }, + { sessionID: "fixture-8", title: "Check narrow layout" }, + { sessionID: "fixture-9", title: "Profile terminal output" }, + { sessionID: "fixture-10", title: "Handle permission" }, + { sessionID: "fixture-11", title: "Run focused tests" }, + { sessionID: "fixture-12", title: "Prepare review" }, +] + +const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false } +const RUN_DURATION = 1_800 +const RESUME_DURATION = 900 + +// Plausible targets for the fake transcript's tool calls, picked per fixture index. +const TRANSCRIPT_FILES = [ + "packages/tui/src/component/session-tabs.tsx", + "packages/tui/src/component/tab-pulse.tsx", + "packages/tui/src/context/session-tabs-model.ts", + "packages/core/src/session/runner.ts", + "packages/server/src/routes/session.ts", + "packages/tui/src/ui/animation.ts", +] + +function SessionTabsStory(props: { context: Plugin.Context }) { + const dimensions = useTerminalDimensions() + const theme = props.context.theme + const elevatedTheme = theme.contextual.elevated + // A keyed store mirrors production: retitles mutate rows in place instead of remounting them. + const [tabStore, setTabStore] = createStore<{ items: { sessionID: string; title?: string }[] }>({ + items: FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })), + }) + const tabs = () => tabStore.items + const setItems = (next: { sessionID: string; title?: string }[]) => + setTabStore("items", reconcile(next, { key: "sessionID" })) + const [active, setActive] = createSignal("fixture-1") + const [lastEvent, setLastEvent] = createSignal("press space to start a random tab") + const [statuses, setStatuses] = createSignal>({}) + // Unread clears on select, so the transcript remembers how each session's last run ended. + const [outcomes, setOutcomes] = createSignal>({}) + const runs = new Map>() + onCleanup(() => runs.forEach(clearTimeout)) + + const number = (sessionID: string) => tabs().findIndex((tab) => tab.sessionID === sessionID) + 1 + + function finishRun(sessionID: string, resumed: boolean) { + runs.delete(sessionID) + if (!tabs().some((item) => item.sessionID === sessionID)) return + const roll = Math.random() + // A permission request pauses the still-busy run until the tab is selected. + if (!resumed && roll < 0.25) { + setStatuses((current) => ({ + ...current, + [sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), attention: true }, + })) + setLastEvent(`tab ${number(sessionID)} needs input; select it to resolve`) + return + } + const failed = roll >= 0.75 + const unread = active() === sessionID ? undefined : failed ? ("error" as const) : ("activity" as const) + batch(() => { + setOutcomes((current) => ({ ...current, [sessionID]: failed ? "failed" : "completed" })) + setStatuses((current) => ({ + ...current, + [sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: false, unread }, + })) + // An untitled session earns its title after its first completed run, like a real summarization. + const index = number(sessionID) - 1 + const fixture = FIXTURE_TABS.find((tab) => tab.sessionID === sessionID) + if (!failed && fixture && tabs()[index]?.title === undefined) setTabStore("items", index, "title", fixture.title) + }) + setLastEvent( + `tab ${number(sessionID)} ${failed ? "failed" : "completed"}${unread ? " (unread)" : " while selected"}`, + ) + } + + const select = (sessionID: string) => { + const status = statuses()[sessionID] + const resumes = status !== undefined && status.attention && status.busy && !runs.has(sessionID) + batch(() => { + setActive(sessionID) + if (status && (status.unread || status.attention)) + setStatuses((current) => ({ ...current, [sessionID]: { ...status, unread: undefined, attention: false } })) + }) + if (resumes) { + setLastEvent(`tab ${number(sessionID)} input resolved, resuming`) + runs.set( + sessionID, + setTimeout(() => finishRun(sessionID, true), RESUME_DURATION), + ) + } + } + + const controller = { + tabs, + current: active, + status(sessionID) { + return statuses()[sessionID] ?? EMPTY_STATUS + }, + select, + move(sessionID: string, index: number) { + const next = moveSessionTab(tabs(), sessionID, index) + if (next === tabs()) return + setItems(next.map((tab) => ({ ...tab }))) + }, + close(sessionID?: string) { + const target = sessionID ?? active() + if (!target) return + const items = tabs() + const index = items.findIndex((tab) => tab.sessionID === target) + if (index === -1) return + const next = items.filter((tab) => tab.sessionID !== target).map((tab) => ({ ...tab })) + const selected = next[index]?.sessionID ?? next[index - 1]?.sessionID + clearTimeout(runs.get(target)) + runs.delete(target) + batch(() => { + setItems(next) + setStatuses((current) => { + const updated = { ...current } + delete updated[target] + return updated + }) + if (active() === target && selected) select(selected) + if (active() === target && !selected) setActive(undefined) + }) + }, + } satisfies SessionTabsController + + const cycle = (direction: 1 | -1) => { + const items = tabs() + if (items.length === 0) return + const index = items.findIndex((tab) => tab.sessionID === active()) + select(items[(index + direction + items.length) % items.length].sessionID) + } + const startRun = (sessionID: string) => { + setStatuses((current) => ({ + ...current, + [sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: true, unread: undefined }, + })) + setOutcomes((current) => { + const next = { ...current } + delete next[sessionID] + return next + }) + setLastEvent(`tab ${number(sessionID)} running`) + runs.set( + sessionID, + setTimeout(() => finishRun(sessionID, false), RUN_DURATION), + ) + } + const randomInactiveTab = () => { + const candidates = tabs().filter((tab) => { + const status = controller.status(tab.sessionID) + return !status.busy && !status.unread && !status.attention + }) + // Untitled sessions run first so their title arrival is easy to trigger. + const untitled = candidates.filter((tab) => tab.title === undefined) + const pool = untitled.length > 0 ? untitled : candidates + return pool[Math.floor(Math.random() * pool.length)] + } + // A fake transcript for the selected session so tab switches feel like moving between real + // sessions; the tail line tracks the live status of the current run. + const transcript = () => { + const current = active() + if (!current) return [{ text: "no session selected", color: theme.text.subdued }] + const index = Math.max( + 0, + FIXTURE_TABS.findIndex((fixture) => fixture.sessionID === current), + ) + const fixture = FIXTURE_TABS[index] + const status = controller.status(current) + const outcome = outcomes()[current] + const file = TRANSCRIPT_FILES[index % TRANSCRIPT_FILES.length] + const lines = [ + { text: `> ${fixture.title}`, color: theme.text.default }, + { text: "", color: theme.text.default }, + ] + if (!status.busy && outcome === undefined) { + lines.push({ text: "no activity yet — press s to run this session", color: theme.text.subdued }) + return lines + } + lines.push( + { text: "● Taking a look — reading the relevant code first.", color: theme.text.default }, + { text: "", color: theme.text.default }, + { text: ` ✱ Read ${file}`, color: theme.text.subdued }, + { text: ` ✱ Edit ${file}`, color: theme.text.subdued }, + { text: ` ✱ Bash bun run test`, color: theme.text.subdued }, + { text: "", color: theme.text.default }, + ) + if (status.attention) + lines.push({ + text: "⚠ Permission required: Bash `bun run test` — select this tab to approve", + color: theme.text.feedback.warning.default, + }) + else if (status.busy) lines.push({ text: "● Working…", color: theme.text.subdued }) + else if (outcome === "failed") + lines.push({ + text: `✗ bun run test failed — 3 tests failing in ${file}`, + color: theme.text.feedback.error.default, + }) + else + lines.push({ + text: `✓ Done — updated ${file} and the tests pass.`, + color: theme.text.feedback.success.default, + }) + return lines + } + + const selectedState = () => { + const current = active() + const status = current ? controller.status(current) : EMPTY_STATUS + const activity = status.busy + ? "running" + : status.unread === "activity" + ? "completed (unread)" + : status.unread === "error" + ? "failed (unread)" + : "read" + return status.attention ? `${activity} + needs input` : activity + } + + props.context.keymap.layer(() => ({ + commands: [ + { + bind: "escape", + title: "Back to storybook", + group: "Storybook", + run() { + props.context.ui.router.navigate({ type: "plugin", name: "storybook" }) + }, + }, + { bind: "left,h", title: "Previous tab", group: "Storybook", run: () => cycle(-1) }, + { bind: "right,l", title: "Next tab", group: "Storybook", run: () => cycle(1) }, + ...Array.from({ length: 10 }, (_, index) => ({ + bind: String((index + 1) % 10), + title: `Select tab ${index + 1}`, + group: "Storybook", + run() { + const tab = tabs()[index] + if (tab) select(tab.sessionID) + }, + })), + { + bind: "space", + title: "Start a random tab", + group: "Storybook", + run() { + const tab = randomInactiveTab() + if (!tab) { + setLastEvent("every tab is busy or unread; select tabs to read them, or press r") + return + } + startRun(tab.sessionID) + }, + }, + { + // Random runs stay off the selected tab, so this is the way to watch the edge flash + // and running sweep under the cursor. + bind: "s", + title: "Run selected tab", + group: "Storybook", + run() { + const current = active() + if (!current) return + if (controller.status(current).busy) { + setLastEvent(`tab ${number(current)} is already running`) + return + } + startRun(current) + }, + }, + { + bind: "t", + title: "Add tab", + group: "Storybook", + run() { + const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID)) + if (!next) { + setLastEvent("all fixture tabs are open") + return + } + setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }]) + select(next.sessionID) + setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`) + }, + }, + { bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() }, + { + bind: "r", + title: "Reset", + group: "Storybook", + run() { + runs.forEach(clearTimeout) + runs.clear() + batch(() => { + setItems(FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab }))) + setStatuses({}) + setOutcomes({}) + setActive("fixture-1") + }) + setLastEvent("reset; press space to start a random tab") + }, + }, + ], + })) + + return ( + + + + + + {(line) => ( + + {line.text || " "} + + )} + + + + + selected: {number(active() ?? "")} | state: {selectedState()} + + background: {lastEvent()} + + + storybook / session tabs + + + space/s run | t add | d close | r reset | ←/→ 1-0 move | drag reorders | esc back + + + + ) +} + +export const sessionTabsStory: Story = { + id: "session-tabs", + title: "Session tabs", + render: (context) => , +} diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index 0c18cdafa6..3e9600cd72 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -7,7 +7,7 @@ import SidebarMcp from "../feature-plugins/sidebar/mcp" import DiffViewer from "../feature-plugins/system/diff-viewer" import Notifications from "../feature-plugins/system/notifications" import Plugins from "../feature-plugins/system/plugins" -import Scrap from "../feature-plugins/system/scrap" +import Storybook from "../feature-plugins/system/storybook" export const builtins = [ HomeFooter, @@ -18,6 +18,6 @@ export const builtins = [ SidebarFooter, Notifications, Plugins, - Scrap, + Storybook, DiffViewer, ] diff --git a/packages/tui/test/component/tab-pulse.test.ts b/packages/tui/test/component/tab-pulse.test.ts index 7d21611984..0e157f6ba3 100644 --- a/packages/tui/test/component/tab-pulse.test.ts +++ b/packages/tui/test/component/tab-pulse.test.ts @@ -10,9 +10,9 @@ import { tint } from "../../src/theme/color" test("completion pulse rises quickly and fades over the remaining duration", () => { expect(completionPulseOpacity(0)).toBe(0) - expect(completionPulseOpacity(0.08)).toBeCloseTo(0.5) - expect(completionPulseOpacity(0.16)).toBe(1) - expect(completionPulseOpacity(0.58)).toBeCloseTo(0.5) + expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5) + expect(completionPulseOpacity(0.12)).toBe(1) + expect(completionPulseOpacity(0.56)).toBeCloseTo(0.5) expect(completionPulseOpacity(1)).toBe(0) }) @@ -44,15 +44,33 @@ test("reuses a color while preserving the original glow and pulse blend stages", const background = RGBA.fromHex("#1a1b26") const glowColor = RGBA.fromHex("#82aaff") const runningColor = RGBA.fromHex("#c8d3f5") + const flashColor = RGBA.fromHex("#e2e8fb") const completionColor = RGBA.fromHex("#ff9e64") for (const glow of [0, 0.08, 0.16]) { for (const running of [0, 0.01, 0.07, 0.14]) { - for (const completion of [0, 0.03, 0.09, 0.18]) { - blendTabPulseColor(output, background, glowColor, runningColor, completionColor, glow, running, completion) - expect(output.buffer).toEqual( - tint(tint(tint(background, glowColor, glow), runningColor, running), completionColor, completion).buffer, - ) + for (const flash of [0, 0.05, 0.1]) { + for (const completion of [0, 0.03, 0.09, 0.18]) { + blendTabPulseColor( + output, + background, + glowColor, + runningColor, + flashColor, + completionColor, + glow, + running, + flash, + completion, + ) + expect(output.buffer).toEqual( + tint( + tint(tint(tint(background, glowColor, glow), runningColor, running), flashColor, flash), + completionColor, + completion, + ).buffer, + ) + } } } } From 94ee274aebf62834a13cde697d7e0f728f6ef284 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 29 Jul 2026 17:23:04 -0400 Subject: [PATCH 41/51] feat(core): add V2 formatter runtime (#39564) --- packages/core/src/file-mutation.ts | 19 +- packages/core/src/formatter.ts | 157 ++++++++++++ packages/core/src/formatter/builtins.ts | 315 ++++++++++++++++++++++++ packages/core/src/location-services.ts | 2 + packages/core/src/plugin/internal.ts | 3 + packages/core/src/plugin/supervisor.ts | 2 + packages/core/src/tool/plugin/edit.ts | 27 +- packages/core/src/tool/plugin/patch.ts | 68 +++-- packages/core/src/tool/plugin/write.ts | 11 +- packages/core/test/formatter.test.ts | 199 +++++++++++++++ packages/core/test/tool-edit.test.ts | 48 +++- packages/core/test/tool-patch.test.ts | 37 ++- packages/core/test/tool-write.test.ts | 39 ++- packages/util/src/bom.ts | 38 +++ packages/util/src/patch.ts | 10 +- 15 files changed, 916 insertions(+), 59 deletions(-) create mode 100644 packages/core/src/formatter.ts create mode 100644 packages/core/src/formatter/builtins.ts create mode 100644 packages/core/test/formatter.test.ts create mode 100644 packages/util/src/bom.ts diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index 82312b22da..b5aee9a526 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect" import { dirname } from "path" import { KeyedMutex } from "./effect/keyed-mutex" import { FSUtil } from "@opencode-ai/util/fs-util" +import { Bom } from "@opencode-ai/util/bom" export interface Target { readonly canonical: string @@ -108,13 +109,13 @@ const layer = Layer.effect( const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) => withTargetLock(input.target)( Effect.gen(function* () { - const next = splitBom(input.content) + const next = Bom.split(input.content) const current = yield* fs .readFile(input.target.canonical) .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) yield* fs.writeWithDirs( input.target.canonical, - joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom), + Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom), ) return writeResult(input.target, current !== undefined) }), @@ -172,20 +173,6 @@ const layer = Layer.effect( }), ) -function splitBom(text: string) { - const stripped = text.replace(/^\uFEFF+/, "") - return { bom: stripped.length !== text.length, text: stripped } -} - -function joinBom(text: string, bom: boolean) { - const stripped = splitBom(text).text - return bom ? `\uFEFF${stripped}` : stripped -} - -function hasUtf8Bom(content: Uint8Array) { - return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf -} - function sameBytes(left: Uint8Array, right: Uint8Array) { if (left.length !== right.length) return false return left.every((byte, index) => byte === right[index]) diff --git a/packages/core/src/formatter.ts b/packages/core/src/formatter.ts new file mode 100644 index 0000000000..4eb6a91c5b --- /dev/null +++ b/packages/core/src/formatter.ts @@ -0,0 +1,157 @@ +export * as Formatter from "./formatter" + +import { Context, Effect, Layer, Schema } from "effect" +import { ChildProcess } from "effect/unstable/process" +import path from "path" +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Npm } from "@opencode-ai/util/npm" +import { AppProcess } from "@opencode-ai/util/process" +import { Config } from "./config" +import { Location } from "./location" +import { make, type Info } from "./formatter/builtins" + +export const Status = Schema.Struct({ + name: Schema.String, + extensions: Schema.Array(Schema.String), + enabled: Schema.Boolean, +}).annotate({ identifier: "FormatterStatus" }) +export type Status = typeof Status.Type + +export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly file: (filepath: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Formatter") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const processes = yield* AppProcess.Service + const commands = new Map() + let formatters: Info[] = [] + + const load = yield* Effect.cached( + Effect.gen(function* () { + const configured = Config.latest(yield* config.entries(), "formatter") + if (!configured) { + yield* Effect.logInfo("all formatters are disabled") + return + } + + const builtIns = make({ + directory: location.directory, + worktree: location.project.directory, + fs, + npm, + processes, + }) + formatters = builtIns + if (configured === true) return + if (configured.ruff?.disabled || configured.uv?.disabled) { + formatters = formatters.filter((formatter) => formatter.name !== "ruff" && formatter.name !== "uv") + } + + for (const [name, entry] of Object.entries(configured)) { + const index = formatters.findIndex((formatter) => formatter.name === name) + if (entry.disabled) { + if (index !== -1) formatters.splice(index, 1) + continue + } + + const builtIn = builtIns.find((formatter) => formatter.name === name) + const formatter: Info = { + name, + extensions: entry.extensions ?? builtIn?.extensions ?? [], + environment: { ...builtIn?.environment, ...entry.environment }, + enabled: + builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false), + } + if (index === -1) formatters.push(formatter) + else formatters[index] = formatter + } + }).pipe(Effect.withSpan("Formatter.load")), + ) + + const command = Effect.fnUntraced(function* (formatter: Info) { + const cached = commands.get(formatter.name) + if (cached !== undefined) return cached + const result = yield* formatter.enabled + if (result !== false) commands.set(formatter.name, result) + return result + }) + + const init = Effect.fn("Formatter.init")(function* () { + yield* load + }) + + const status = Effect.fn("Formatter.status")(function* () { + yield* load + return yield* Effect.forEach(formatters, (formatter) => + command(formatter).pipe( + Effect.map((enabled) => ({ + name: formatter.name, + extensions: [...formatter.extensions], + enabled: enabled !== false, + })), + ), + ) + }) + + const file = Effect.fn("Formatter.file")(function* (filepath: string) { + yield* load + const matching = formatters.filter((formatter) => + formatter.extensions.includes(path.extname(filepath)), + ) + + for (const formatter of matching) { + const enabled = yield* command(formatter) + if (enabled === false) continue + const cmd = enabled.map((argument) => argument.replace("$FILE", filepath)) + yield* Effect.logInfo("formatting file", { file: filepath, command: cmd }) + const result = yield* processes + .run( + ChildProcess.make(cmd[0], cmd.slice(1), { + cwd: location.directory, + env: formatter.environment, + extendEnv: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ) + .pipe( + Effect.catch((error) => + Effect.logError("failed to format file", { + file: filepath, + command: cmd, + error: error.message, + }).pipe(Effect.as(undefined)), + ), + ) + if (!result) continue + if (result.exitCode === 0) return true + yield* Effect.logError("formatter exited unsuccessfully", { + file: filepath, + command: cmd, + exitCode: result.exitCode, + }) + } + return false + }) + + return Service.of({ init, status, file }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node], +}) diff --git a/packages/core/src/formatter/builtins.ts b/packages/core/src/formatter/builtins.ts new file mode 100644 index 0000000000..9c9c51ab14 --- /dev/null +++ b/packages/core/src/formatter/builtins.ts @@ -0,0 +1,315 @@ +import { Effect } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Npm } from "@opencode-ai/util/npm" +import { AppProcess } from "@opencode-ai/util/process" +import { which } from "../util/which" + +export interface Info { + readonly name: string + readonly environment?: Record + readonly extensions: readonly string[] + readonly enabled: Effect.Effect +} + +export function make(input: { + readonly directory: string + readonly worktree: string + readonly fs: FSUtil.Interface + readonly npm: Npm.Interface + readonly processes: AppProcess.Interface + readonly experimentalOxfmt?: boolean +}) { + const disabled = false as const + const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree) + const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => "")) + const commandOutput = (command: string[]) => + input.processes + .run( + ChildProcess.make(command[0], command.slice(1), { + cwd: input.directory, + extendEnv: true, + stdin: "ignore", + }), + ) + .pipe(Effect.option) + + const gofmt: Info = { + name: "gofmt", + extensions: [".go"], + enabled: Effect.sync(() => { + const match = which("gofmt") + return match ? [match, "-w", "$FILE"] : disabled + }), + } + + const mix: Info = { + name: "mix", + extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], + enabled: Effect.sync(() => { + const match = which("mix") + return match ? [match, "format", "$FILE"] : disabled + }), + } + + const prettier: Info = { + name: "prettier", + environment: { BUN_BE_BUN: "1" }, + extensions: [ + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".mts", + ".cts", + ".html", + ".htm", + ".css", + ".scss", + ".sass", + ".less", + ".vue", + ".svelte", + ".json", + ".jsonc", + ".yaml", + ".yml", + ".toml", + ".xml", + ".md", + ".mdx", + ".graphql", + ".gql", + ], + enabled: Effect.gen(function* () { + for (const file of yield* findUp("package.json")) { + if (!hasDependency(yield* input.fs.readJson(file), "prettier")) continue + const bin = yield* input.npm.which("prettier") + if (bin) return [bin, "--write", "$FILE"] + } + return disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const oxfmt: Info = { + name: "oxfmt", + environment: { BUN_BE_BUN: "1" }, + extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"], + enabled: Effect.gen(function* () { + for (const file of yield* findUp("package.json")) { + if (!hasDependency(yield* input.fs.readJson(file), "oxfmt")) continue + const bin = yield* input.npm.which("oxfmt") + if (bin) return [bin, "$FILE"] + } + return disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const biome: Info = { + name: "biome", + environment: { BUN_BE_BUN: "1" }, + extensions: [ + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".mts", + ".cts", + ".html", + ".htm", + ".css", + ".scss", + ".sass", + ".less", + ".vue", + ".svelte", + ".json", + ".jsonc", + ".yaml", + ".yml", + ".toml", + ".xml", + ".md", + ".mdx", + ".graphql", + ".gql", + ], + enabled: Effect.gen(function* () { + const found = yield* Effect.forEach(["biome.json", "biome.jsonc"], findUp, { concurrency: "unbounded" }) + if (!found.some((items) => items.length > 0)) return disabled + const bin = yield* input.npm.which("@biomejs/biome") + return bin ? [bin, "format", "--write", "$FILE"] : disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const zig: Info = { + name: "zig", + extensions: [".zig", ".zon"], + enabled: Effect.sync(() => { + const match = which("zig") + return match ? [match, "fmt", "$FILE"] : disabled + }), + } + + const clang: Info = { + name: "clang-format", + extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], + enabled: Effect.gen(function* () { + if (!(yield* findUp(".clang-format")).length) return disabled + const match = which("clang-format") + return match ? [match, "-i", "$FILE"] : disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const ktlint: Info = { + name: "ktlint", + extensions: [".kt", ".kts"], + enabled: Effect.sync(() => { + const match = which("ktlint") + return match ? [match, "-F", "$FILE"] : disabled + }), + } + + const ruff: Info = { + name: "ruff", + extensions: [".py", ".pyi"], + enabled: Effect.gen(function* () { + if (!which("ruff")) return disabled + for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) { + const found = yield* findUp(config) + if (!found.length) continue + if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) { + return ["ruff", "format", "$FILE"] + } + } + for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) { + const found = yield* findUp(dependency) + if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"] + } + return disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const air: Info = { + name: "air", + extensions: [".R"], + enabled: Effect.gen(function* () { + const bin = which("air") + if (!bin) return disabled + const output = yield* commandOutput([bin, "--help"]) + if (output._tag === "None" || output.value.exitCode !== 0) return disabled + const first = output.value.stdout.toString("utf8").split("\n")[0] + return first.includes("R language") && first.includes("formatter") ? [bin, "format", "$FILE"] : disabled + }), + } + + const uv: Info = { + name: "uv", + extensions: [".py", ".pyi"], + enabled: Effect.gen(function* () { + const bin = which("uv") + if (!bin) return disabled + const output = yield* commandOutput([bin, "format", "--help"]) + return output._tag === "Some" && output.value.exitCode === 0 + ? [bin, "format", "--", "$FILE"] + : disabled + }), + } + + const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"]) + const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"]) + const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"]) + const dart = executable("dart", [".dart"], ["format", "$FILE"]) + + const ocamlformat: Info = { + name: "ocamlformat", + extensions: [".ml", ".mli"], + enabled: Effect.gen(function* () { + if (!(yield* findUp(".ocamlformat")).length) return disabled + const match = which("ocamlformat") + return match ? [match, "-i", "$FILE"] : disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"]) + const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"]) + const gleam = executable("gleam", [".gleam"], ["format", "$FILE"]) + const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"]) + const nixfmt = executable("nixfmt", [".nix"], ["$FILE"]) + const rustfmt = executable("rustfmt", [".rs"], ["$FILE"]) + + const pint: Info = { + name: "pint", + extensions: [".php"], + enabled: Effect.gen(function* () { + for (const file of yield* findUp("composer.json")) { + const json = yield* input.fs.readJson(file) + if (hasRecordKey(json, "require", "laravel/pint") || hasRecordKey(json, "require-dev", "laravel/pint")) { + return ["./vendor/bin/pint", "$FILE"] + } + } + return disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"]) + const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"]) + const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"]) + + return [ + gofmt, + mix, + oxfmt, + prettier, + biome, + zig, + clang, + ktlint, + ruff, + air, + uv, + rubocop, + standardrb, + htmlbeautifier, + dart, + ocamlformat, + terraform, + latexindent, + gleam, + shfmt, + nixfmt, + rustfmt, + pint, + ormolu, + cljfmt, + dfmt, + ] satisfies Info[] +} + +function executable(name: string, extensions: readonly string[], args: string[]): Info { + return { + name, + extensions, + enabled: Effect.sync(() => { + const match = which(name) + return match ? [match, ...args] : false + }), + } +} + +function hasDependency(input: unknown, dependency: string) { + return hasRecordKey(input, "dependencies", dependency) || hasRecordKey(input, "devDependencies", dependency) +} + +function hasRecordKey(input: unknown, field: string, key: string) { + if (!isRecord(input)) return false + return isRecord(input[field]) && key in input[field] +} + +function isRecord(input: unknown): input is Record { + return Boolean(input && typeof input === "object" && !Array.isArray(input)) +} diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 7a39aa278a..781dce902c 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -8,6 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Node } from "@opencode-ai/util/effect/app-node" import { Bus } from "./bus" import { FileMutation } from "./file-mutation" +import { Formatter } from "./formatter" import { FileSystem } from "./filesystem" import { FileSystemSearch } from "./filesystem/search" import { Generate } from "./generate" @@ -73,6 +74,7 @@ const locationServiceNodes = [ InstructionDiscovery.node, LocationMutation.node, FileMutation.node, + Formatter.node, MCP.node, Permission.node, Tool.node, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index b46a6ae746..3327bef396 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -16,6 +16,7 @@ import { ConfigSkillPlugin } from "../config/plugin/skill" import { ConfigWebSearchPlugin } from "../config/plugin/websearch" import { Bus } from "../bus" import { FileMutation } from "../file-mutation" +import { Formatter } from "../formatter" import { Form } from "../form" import { FileSystem } from "../filesystem" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -68,6 +69,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const config = yield* Config.Service const bus = yield* Bus.Service const mutation = yield* FileMutation.Service + const formatter = yield* Formatter.Service const filesystem = yield* FileSystem.Service const fs = yield* FSUtil.Service const global = yield* Global.Service @@ -98,6 +100,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(Config.Service, config), Context.make(Bus.Service, bus), Context.make(FileMutation.Service, mutation), + Context.make(Formatter.Service, formatter), Context.make(FileSystem.Service, filesystem), Context.make(FSUtil.Service, fs), Context.make(Global.Service, global), diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index bcca1f53fa..9472637343 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -14,6 +14,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { Bus } from "../bus" import { FileMutation } from "../file-mutation" +import { Formatter } from "../formatter" import { FileSystem } from "../filesystem" import { Watcher } from "../filesystem/watcher" import { Form } from "../form" @@ -318,6 +319,7 @@ export const node = makeLocationNode({ Config.node, Bus.node, FileMutation.node, + Formatter.node, FileSystem.node, FSUtil.node, Global.node, diff --git a/packages/core/src/tool/plugin/edit.ts b/packages/core/src/tool/plugin/edit.ts index 57aacecb60..2babc4be48 100644 --- a/packages/core/src/tool/plugin/edit.ts +++ b/packages/core/src/tool/plugin/edit.ts @@ -9,9 +9,11 @@ export * as EditTool from "./edit" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import { ToolFailure } from "@opencode-ai/ai" import { FileDiff } from "@opencode-ai/schema/file-diff" +import { Bom } from "@opencode-ai/util/bom" import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Schema } from "effect" import { FileMutation } from "../../file-mutation" +import { Formatter } from "../../formatter" import { FSUtil } from "@opencode-ai/util/fs-util" import { LocationMutation } from "../../location-mutation" import { Permission } from "../../permission" @@ -99,7 +101,6 @@ const findLineOccurrences = (content: string, search: string) => { } /** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */ -// TODO: Add formatter integration after formatter runtime exists. // TODO: Publish watcher/file-edit events after watcher integration exists. // TODO: Add snapshots / undo after design exists. // TODO: Add LSP notification and diagnostics after LSP runtime exists. @@ -109,6 +110,7 @@ export const Plugin = { effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service + const formatter = yield* Formatter.Service const fs = yield* FSUtil.Service const permission = yield* Permission.Service @@ -167,9 +169,8 @@ export const Plugin = { if (info.type === "Directory") { return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }) } - const bytes = yield* fs.readFile(target.canonical) - const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf - const source = new TextDecoder().decode(bom ? bytes.slice(3) : bytes) + const original = yield* Bom.readFile(fs, target.canonical) + const source = original.text const ending = source.includes(crlf) ? crlf : "\n" const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending) const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending) @@ -201,23 +202,27 @@ export const Plugin = { `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`, source, ) - const counts = diffLines(source, replaced).reduce( + const replacementBom = replaced.startsWith("\uFEFF") + const result = yield* files.write({ + target, + content: Bom.join(replaced, original.bom || replacementBom), + }) + const bom = original.bom || replacementBom + const formatted = (yield* formatter.file(target.canonical)) + ? yield* Bom.syncFile(fs, target.canonical, bom) + : (yield* Bom.readFile(fs, target.canonical)).text + const counts = diffLines(source, formatted).reduce( (result, item) => ({ additions: result.additions + (item.added ? (item.count ?? 0) : 0), deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), }), { additions: 0, deletions: 0 }, ) - const replacementBom = replaced.startsWith("\uFEFF") - const result = yield* files.write({ - target, - content: `${bom || replacementBom ? "\uFEFF" : ""}${replacementBom ? replaced.slice(1) : replaced}`, - }) return { files: [ { file: result.resource, - patch: createTwoFilesPatch(result.resource, result.resource, source, replaced), + patch: createTwoFilesPatch(result.resource, result.resource, source, formatted), status: "modified" as const, ...counts, }, diff --git a/packages/core/src/tool/plugin/patch.ts b/packages/core/src/tool/plugin/patch.ts index ea29a4829c..0433b47e9b 100644 --- a/packages/core/src/tool/plugin/patch.ts +++ b/packages/core/src/tool/plugin/patch.ts @@ -7,7 +7,9 @@ import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Schema } from "effect" import { PlatformError } from "effect/PlatformError" import path from "path" +import { Bom } from "@opencode-ai/util/bom" import { FSUtil } from "@opencode-ai/util/fs-util" +import { Formatter } from "../../formatter" import { Location } from "../../location" import { Patch } from "@opencode-ai/util/patch" import { Permission } from "../../permission" @@ -68,6 +70,7 @@ export const Plugin = { id: "opencode.tool.patch", effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) { const fs = yield* FSUtil.Service + const formatter = yield* Formatter.Service const location = yield* Location.Service const permission = yield* Permission.Service @@ -129,15 +132,16 @@ export const Plugin = { ...hunk, target, before: "", - after: (hunk.contents.endsWith("\n") || hunk.contents === "" - ? hunk.contents - : `${hunk.contents}\n` - ).replace(/^\uFEFF/, ""), + after: Bom.split( + hunk.contents.endsWith("\n") || hunk.contents === "" + ? hunk.contents + : `${hunk.contents}\n`, + ).text, }) return } if (hunk.type === "delete") { - const content = yield* fs.readFile(target.canonical).pipe( + const content = yield* Bom.readFile(fs, target.canonical).pipe( Effect.mapError( (error) => new ToolFailure({ @@ -145,8 +149,7 @@ export const Plugin = { }), ), ) - const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content) - prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" }) + prepared.push({ ...hunk, target, before: content.text, after: "" }) return } const previous = updates.get(target.canonical) @@ -166,18 +169,17 @@ export const Plugin = { message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`, }) } - return new TextDecoder("utf-8", { ignoreBOM: true }).decode( - yield* fs.readFile(target.canonical).pipe( - Effect.mapError( - (error) => - new ToolFailure({ - message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`, - }), - ), + const content = yield* Bom.readFile(fs, target.canonical).pipe( + Effect.mapError( + (error) => + new ToolFailure({ + message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`, + }), ), ) + return Bom.join(content.text, content.bom) })) - const before = original.replace(/^\uFEFF/, "") + const before = Bom.split(original).text const update = yield* Effect.try({ try: () => Patch.derive(hunk.path, hunk.chunks, original), catch: (error) => @@ -217,7 +219,7 @@ export const Plugin = { ) } - const patchFiles = prepared.map(patchFile) + const patchFiles = prepared.map((change) => patchFile(change)) yield* permission.assert({ action: "edit", resources: [...new Set(targets.map((target) => target.resource))], @@ -295,7 +297,31 @@ export const Plugin = { }), { discard: true }, ) - return { applied, files: patchFiles } + const formatted = new Map() + yield* Effect.forEach( + [...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))], + (target) => + Effect.gen(function* () { + const current = yield* Bom.readFile(fs, target).pipe( + Effect.mapError((error) => fail(`Failed to read ${target}`, error)), + ) + formatted.set( + target, + (yield* formatter.file(target)) + ? yield* Bom.syncFile(fs, target, current.bom).pipe( + Effect.mapError((error) => fail(`Failed to sync ${target}`, error)), + ) + : current.text, + ) + }), + { discard: true }, + ) + const files = yield* Effect.forEach(prepared, (change) => { + if (change.type === "delete") return Effect.succeed(patchFile(change)) + const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target + return Effect.succeed(patchFile(change, formatted.get(target.canonical))) + }) + return { applied, files } }).pipe( Effect.map((output) => ({ output, @@ -337,15 +363,15 @@ function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error) } -function patchFile(change: Prepared): typeof FileDiff.Info.Type { +function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type { const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource const patch = trimDiff( - createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, change.after), + createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after), ) const counts = change.type === "delete" ? { additions: 0, deletions: change.before.split("\n").length } - : diffLines(change.before, change.after).reduce( + : diffLines(change.before, after).reduce( (result, item) => ({ additions: result.additions + (item.added ? (item.count ?? 0) : 0), deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), diff --git a/packages/core/src/tool/plugin/write.ts b/packages/core/src/tool/plugin/write.ts index d5e9411a52..ea8594813a 100644 --- a/packages/core/src/tool/plugin/write.ts +++ b/packages/core/src/tool/plugin/write.ts @@ -9,7 +9,10 @@ export * as WriteTool from "./write" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema } from "effect" +import { Bom } from "@opencode-ai/util/bom" +import { FSUtil } from "@opencode-ai/util/fs-util" import { FileMutation } from "../../file-mutation" +import { Formatter } from "../../formatter" import { LocationMutation } from "../../location-mutation" import { Permission } from "../../permission" @@ -36,7 +39,6 @@ export const toModelOutput = (output: Output) => `${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}` /** Deferred write UX integrations remain visible at the model-facing seam. */ -// TODO: Add formatter integration after formatter runtime exists. // TODO: Publish watcher/file-edit events after watcher integration exists. // TODO: Add snapshots / undo after design exists. // TODO: Add LSP notification and diagnostics after LSP runtime exists. @@ -46,6 +48,8 @@ export const Plugin = { effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service + const formatter = yield* Formatter.Service + const fs = yield* FSUtil.Service const permission = yield* Permission.Service yield* ctx.tool @@ -82,7 +86,10 @@ export const Plugin = { agent: context.agent, source, }) - return yield* files.writeTextPreservingBom({ target, content: input.content }) + const result = yield* files.writeTextPreservingBom({ target, content: input.content }) + const bom = (yield* Bom.readFile(fs, target.canonical)).bom + if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom) + return result }).pipe( Effect.map((output) => ({ output, content: toModelOutput(output) })), Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })), diff --git a/packages/core/test/formatter.test.ts b/packages/core/test/formatter.test.ts new file mode 100644 index 0000000000..fbacfb971b --- /dev/null +++ b/packages/core/test/formatter.test.ts @@ -0,0 +1,199 @@ +import fs from "fs/promises" +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema, Stream } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Npm } from "@opencode-ai/util/npm" +import { Config } from "../src/config" +import { Formatter } from "../src/formatter" +import { Location } from "../src/location" +import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect(Layer.empty) +type ConfigInput = typeof Config.Info.Encoded + +function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) { + const entries = + configured === undefined + ? [] + : [ + new Config.Document({ + type: "document", + info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }), + }), + ] + return AppNodeBuilder.build(Formatter.node, [ + [ + Config.node, + Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => Effect.succeed(entries), + changes: () => Stream.empty, + }), + ), + ], + [ + Location.node, + Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(directory) })), + ), + ], + [Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })], + ]) +} + +function withTemp(body: (directory: string) => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => body(tmp.path), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) +} + +describe("Formatter", () => { + it.live("status() returns empty list when no formatters are configured", () => + withTemp((directory) => + Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))), + ), + ) + + it.live("status() returns built-in formatters when formatter is true", () => + withTemp((directory) => + Formatter.Service.use((formatter) => + Effect.gen(function* () { + const statuses = yield* formatter.status() + const gofmt = statuses.find((item) => item.name === "gofmt") + expect(gofmt).toBeDefined() + expect(gofmt?.extensions).toContain(".go") + }), + ).pipe(Effect.provide(formatterLayer(directory, true))), + ), + ) + + it.live("status() keeps built-in formatters when config object is provided", () => + withTemp((directory) => + Formatter.Service.use((formatter) => + Effect.gen(function* () { + const statuses = yield* formatter.status() + expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go") + expect(statuses.find((item) => item.name === "mix")).toBeDefined() + }), + ).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))), + ), + ) + + it.live("status() excludes formatters marked as disabled in config", () => + withTemp((directory) => + Formatter.Service.use((formatter) => + Effect.gen(function* () { + const statuses = yield* formatter.status() + expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined() + expect(statuses.find((item) => item.name === "mix")).toBeDefined() + }), + ).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))), + ), + ) + + it.live("service initializes without error", () => + withTemp((directory) => + Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))), + ), + ) + + it.live("file() returns false when no formatter runs", () => + withTemp((directory) => + Effect.gen(function* () { + const file = path.join(directory, "test.txt") + yield* Effect.promise(() => fs.writeFile(file, "x")) + expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false) + }).pipe(Effect.provide(formatterLayer(directory, false))), + ), + ) + + it.live("status() initializes formatter state per directory", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([off, on]) => + Effect.gen(function* () { + const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe( + Effect.provide(formatterLayer(off.path, false)), + ) + const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe( + Effect.provide(formatterLayer(on.path, true)), + ) + expect(disabled).toEqual([]) + expect(enabled.find((item) => item.name === "gofmt")).toBeDefined() + }), + (directories) => + Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)), + ), + ) + + it.live("stops after the first matching formatter succeeds", () => + withTemp((directory) => + Effect.gen(function* () { + const file = path.join(directory, "test.seq") + yield* Effect.promise(() => fs.writeFile(file, "x")) + expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA") + }).pipe( + Effect.provide( + formatterLayer(directory, { + first: { + command: [ + process.execPath, + "-e", + "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')", + "$FILE", + ], + extensions: [".seq"], + }, + second: { + command: [ + process.execPath, + "-e", + "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')", + "$FILE", + ], + extensions: [".seq"], + }, + }), + ), + ), + ), + ) + + it.live("tries the next matching formatter when the first fails", () => + withTemp((directory) => + Effect.gen(function* () { + const file = path.join(directory, "test.fallback") + yield* Effect.promise(() => fs.writeFile(file, "x")) + expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB") + }).pipe( + Effect.provide( + formatterLayer(directory, { + first: { + command: [process.execPath, "-e", "process.exit(1)", "$FILE"], + extensions: [".fallback"], + }, + second: { + command: [ + process.execPath, + "-e", + "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')", + "$FILE", + ], + extensions: [".fallback"], + }, + }), + ), + ), + ), + ) +}) diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 6e4f11a1d9..3890497ebd 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -5,6 +5,7 @@ import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FileMutation } from "@opencode-ai/core/file-mutation" +import { Formatter } from "@opencode-ai/core/formatter" import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" @@ -22,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from " const editToolNode = makeLocationNode({ name: "test/edit-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)), - deps: [Tool.node, LocationMutation.node, FileMutation.node, FSUtil.node, Permission.node], + deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node], }) const sessionID = Session.ID.make("ses_edit_tool_test") @@ -31,6 +32,7 @@ const writes: string[] = [] let reads = 0 let denyAction: string | undefined let afterRead = (_target: string, _content: Uint8Array): Effect.Effect => Effect.void +let formatFile = (_target: string): Effect.Effect => Effect.succeed(false) const permission = Layer.succeed( Permission.Service, @@ -57,12 +59,17 @@ const permission = Layer.succeed( }), ) +const formatter = Layer.mock(Formatter.Service, { + file: (target) => formatFile(target), +}) + const reset = () => { assertions.length = 0 writes.length = 0 reads = 0 denyAction = undefined afterRead = () => Effect.void + formatFile = () => Effect.succeed(false) } const filesystem = Layer.effect( @@ -109,6 +116,7 @@ const withTool = (directory: string, body: (registry: Tool.Interface) = [ [FSUtil.node, filesystem], [Location.node, activeLocation], + [Formatter.node, formatter], [Permission.node, permission], ], ), @@ -181,6 +189,39 @@ describe("EditTool", () => { ), ) + it.live("returns the diff for final formatted content", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const target = path.join(tmp.path, "formatted.txt") + formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("after", "AFTER")) + return true + }) + return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( + Effect.andThen( + withTool(tmp.path, (registry) => + Effect.gen(function* () { + const settled = yield* executeTool( + registry, + call({ path: "formatted.txt", oldString: "before", newString: "after" }), + ) + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.output.files[0]?.patch).toContain("-before\n+AFTER") + expect(settled.metadata?.files?.[0]?.patch).toContain("-before\n+AFTER") + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("AFTER\n") + }), + ), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("accepts an absolute file path inside the active Location", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -574,6 +615,11 @@ describe("EditTool", () => { (tmp) => { reset() const target = path.join(tmp.path, "windows.txt") + formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, "")) + return true + }) return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe( Effect.andThen( withTool(tmp.path, (registry) => diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 471bf9e795..4a1a2e095f 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -6,6 +6,7 @@ import { systemError } from "effect/PlatformError" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FSUtil } from "@opencode-ai/util/fs-util" +import { Formatter } from "@opencode-ai/core/formatter" import { Location } from "@opencode-ai/core/location" import { Permission } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -21,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from " const patchToolNode = makeLocationNode({ name: "test/patch-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), - deps: [Tool.node, FSUtil.node, Location.node, Permission.node], + deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node], }) const sessionID = Session.ID.make("ses_patch_tool_test") @@ -33,6 +34,7 @@ let failWriteTarget: string | undefined let readsBeforeEditApproval = 0 let editApproved = false let afterEditApproval = (): Effect.Effect => Effect.void +let formatFile = (_target: string): Effect.Effect => Effect.succeed(false) const permission = Layer.succeed( Permission.Service, @@ -63,6 +65,10 @@ const permission = Layer.succeed( }), ) +const formatter = Layer.mock(Formatter.Service, { + file: (target) => formatFile(target), +}) + const reset = () => { assertions.length = 0 denyAction = undefined @@ -72,6 +78,7 @@ const reset = () => { readsBeforeEditApproval = 0 editApproved = false afterEditApproval = () => Effect.void + formatFile = () => Effect.succeed(false) } const filesystem = Layer.effect( @@ -135,6 +142,7 @@ const withTool = ( AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [ [FSUtil.node, filesystem], [Location.node, activeLocation], + [Formatter.node, formatter], [Permission.node, permission], ]), ), @@ -254,6 +262,28 @@ describe("PatchTool", () => { ), ) + it.live("returns file diffs for final formatted content", () => + withTempTool((directory, registry) => { + const target = path.join(directory, "formatted.txt") + formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("created", "FORMATTED")) + return true + }) + return Effect.gen(function* () { + const settled = yield* executeTool( + registry, + call("*** Begin Patch\n*** Add File: formatted.txt\n+created\n*** End Patch"), + ) + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.output.files[0]?.patch).toContain("+FORMATTED") + expect(settled.metadata?.files?.[0]?.patch).toContain("+FORMATTED") + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMATTED\n") + }) + }), + ) + it.live("moves and updates a file", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -552,6 +582,11 @@ describe("PatchTool", () => { const bom = "\uFEFF" const target = path.join(directory, "example.cs") yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`)) + formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, "")) + return true + }) const settled = yield* executeTool( registry, call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"), diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index cd2781189f..0c7dd951e8 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -3,6 +3,7 @@ import path from "path" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { FileMutation } from "@opencode-ai/core/file-mutation" +import { Formatter } from "@opencode-ai/core/formatter" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -22,12 +23,13 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from " const writeToolNode = makeLocationNode({ name: "test/write-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)), - deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node], + deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node], }) const sessionID = Session.ID.make("ses_write_tool_test") const assertions: Permission.AssertInput[] = [] const writes: string[] = [] +let formatFile = (_target: string): Effect.Effect => Effect.succeed(false) let denyAction: string | undefined const permission = Layer.succeed( @@ -55,9 +57,14 @@ const permission = Layer.succeed( }), ) +const formatter = Layer.mock(Formatter.Service, { + file: (target) => formatFile(target), +}) + const reset = () => { assertions.length = 0 writes.length = 0 + formatFile = () => Effect.succeed(false) denyAction = undefined } @@ -93,6 +100,7 @@ const withTool = (directory: string, body: (registry: Tool.Interface) = [ [FSUtil.node, filesystem], [Location.node, activeLocation], + [Formatter.node, formatter], [Permission.node, permission], ], ), @@ -140,6 +148,30 @@ describe("WriteTool", () => { ), ) + it.live("formats the committed file", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const target = path.join(tmp.path, "formatted.txt") + formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase()) + return true + }) + return withTool(tmp.path, (registry) => + Effect.gen(function* () { + expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({ + status: "completed", + }) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME") + }), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("overwrites a relative existing file and reports that it wrote the file", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -174,6 +206,11 @@ describe("WriteTool", () => { reset() const preserved = path.join(tmp.path, "preserved.txt") const deduplicated = path.join(tmp.path, "deduplicated.txt") + formatFile = (target) => + Effect.promise(async () => { + await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`) + return true + }) return Effect.promise(() => Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]), ).pipe( diff --git a/packages/util/src/bom.ts b/packages/util/src/bom.ts new file mode 100644 index 0000000000..776ca118a9 --- /dev/null +++ b/packages/util/src/bom.ts @@ -0,0 +1,38 @@ +export * as Bom from "./bom.js" + +import { Effect } from "effect" +import { FSUtil } from "./fs-util.js" + +const code = 0xfeff +const value = String.fromCharCode(code) + +export function split(text: string) { + const stripped = text.replace(/^\uFEFF+/, "") + return { bom: stripped.length !== text.length, text: stripped } +} + +export function join(text: string, bom: boolean) { + const stripped = split(text).text + return bom ? value + stripped : stripped +} + +export function has(content: Uint8Array) { + return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf +} + +export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) { + return split(decode(yield* fs.readFile(filepath))) +}) + +export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filepath: string, bom: boolean) { + const decoded = decode(yield* fs.readFile(filepath)) + const current = split(decoded) + const canonical = join(current.text, bom) + if (decoded === canonical) return current.text + yield* fs.writeWithDirs(filepath, canonical) + return current.text +}) + +function decode(content: Uint8Array) { + return new TextDecoder("utf-8", { ignoreBOM: true }).decode(content) +} diff --git a/packages/util/src/patch.ts b/packages/util/src/patch.ts index 54766c5a61..11aadd52bb 100644 --- a/packages/util/src/patch.ts +++ b/packages/util/src/patch.ts @@ -1,6 +1,7 @@ export * as Patch from "./patch.js" import { Result, Schema } from "effect" +import { Bom } from "./bom.js" export class BoundaryError extends Schema.TaggedErrorClass()("Patch.BoundaryError", { boundary: Schema.Literals(["first", "last"]), @@ -125,20 +126,19 @@ export function parse(patchText: string): Result.Result, Par } export function derive(path: string, chunks: ReadonlyArray, original: string): FileUpdate { - const source = splitBom(original) + const source = Bom.split(original) const lines = source.text.split("\n") if (lines.at(-1) === "") lines.pop() const replacements = computeReplacements(lines, path, chunks) const updated = [...lines] for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert) if (updated.at(-1) !== "") updated.push("") - const next = splitBom(updated.join("\n")) + const next = Bom.split(updated.join("\n")) return { content: next.text, bom: source.bom || next.bom } } export function joinBom(text: string, bom: boolean) { - const stripped = splitBom(text).text - return bom ? `\uFEFF${stripped}` : stripped + return Bom.join(text, bom) } function parseAdd( @@ -379,6 +379,4 @@ const normalize = (value: string) => .replace(/[“”„‟]/g, '"') .replace(/[‐‑‒–—―−]/g, "-") .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ") -const splitBom = (text: string) => - text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text } const stripHeredoc = (input: string) => input.match(/^(?:cat\s+)?<<(['"]?)(\w+)\1\s*\n([\s\S]*?)\n\2\s*$/)?.[3] ?? input From d1a02b149cd438acaa39f8d4f64bd3f608e69799 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:48:49 -0500 Subject: [PATCH 42/51] fix(core): clarify subagent tool guidance (#39572) --- packages/core/src/tool/plugin/subagent.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index b7d5facb18..2c73e79f91 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -13,15 +13,19 @@ export const name = "subagent" const NO_TEXT = "Subagent completed without a text response." const backgroundStarted = (sessionID: SessionSchema.ID) => - `The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress.` + [ + `The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`, + "DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.", + "Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.", + ].join("\n") export const Input = Schema.Struct({ - agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }), - description: Schema.String.annotate({ description: "A short description of the subagent's task" }), + agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), + description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }), prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }), background: Schema.optionalKey(Schema.Boolean).annotate({ description: - "Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.", + "Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.", }), }) @@ -31,7 +35,8 @@ export const Output = Schema.Struct({ output: Schema.String, }) export const description = [ - "Spawn a subagent: a child session running a configured agent with fresh context.", + "Spawns an agent in a child session to work on the specified task.", + "Include all relevant context and instructions in the prompt because the child starts with fresh context.", "Foreground (default) runs the subagent to completion and returns its final response.", "Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.", "Use background only for independent work that can run while you continue elsewhere.", From 0ece10af43dac063dfbbf608bb1d2685d44bf2dd Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:04:13 -0500 Subject: [PATCH 43/51] fix(core): add mutation permission previews (#39578) --- packages/core/src/tool/plugin/edit.ts | 68 ++++++++++++++------------ packages/core/src/tool/plugin/write.ts | 25 ++++++++-- packages/core/test/tool-edit.test.ts | 17 +++++-- packages/core/test/tool-write.test.ts | 22 +++++++++ 4 files changed, 95 insertions(+), 37 deletions(-) diff --git a/packages/core/src/tool/plugin/edit.ts b/packages/core/src/tool/plugin/edit.ts index 2babc4be48..ccab3ff8eb 100644 --- a/packages/core/src/tool/plugin/edit.ts +++ b/packages/core/src/tool/plugin/edit.ts @@ -153,14 +153,6 @@ export const Plugin = { }) } - yield* permission.assert({ - action: "edit", - resources: [target.resource], - save: ["*"], - sessionID: context.sessionID, - agent: context.agent, - source: permissionSource, - }) const info = yield* fs.stat(target.canonical).pipe( Effect.catchReason("PlatformError", "NotFound", () => Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })), @@ -184,6 +176,26 @@ export const Plugin = { : findLineOccurrences(source, oldString) const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing const replacements = matches.length + const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1)) + .toReversed() + .reduce( + (content, match) => + `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`, + source, + ) + const preview = + replacements > 0 && (replacements === 1 || input.replaceAll === true) + ? fileDiff(target.resource, source, replaced) + : undefined + yield* permission.assert({ + action: "edit", + resources: [target.resource], + save: ["*"], + metadata: preview ? { files: [preview] } : undefined, + sessionID: context.sessionID, + agent: context.agent, + source: permissionSource, + }) if (replacements === 0) { return yield* new ToolFailure({ message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`, @@ -194,14 +206,6 @@ export const Plugin = { message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`, }) } - - const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1)) - .toReversed() - .reduce( - (content, match) => - `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`, - source, - ) const replacementBom = replaced.startsWith("\uFEFF") const result = yield* files.write({ target, @@ -211,22 +215,8 @@ export const Plugin = { const formatted = (yield* formatter.file(target.canonical)) ? yield* Bom.syncFile(fs, target.canonical, bom) : (yield* Bom.readFile(fs, target.canonical)).text - const counts = diffLines(source, formatted).reduce( - (result, item) => ({ - additions: result.additions + (item.added ? (item.count ?? 0) : 0), - deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), - }), - { additions: 0, deletions: 0 }, - ) return { - files: [ - { - file: result.resource, - patch: createTwoFilesPatch(result.resource, result.resource, source, formatted), - status: "modified" as const, - ...counts, - }, - ], + files: [fileDiff(result.resource, source, formatted)], replacements, } satisfies Output }).pipe( @@ -248,3 +238,19 @@ export const Plugin = { .pipe(Effect.orDie) }), } + +function fileDiff(file: string, before: string, after: string): typeof FileDiff.Info.Type { + const counts = diffLines(before, after).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) + return { + file, + patch: createTwoFilesPatch(file, file, before, after), + status: "modified", + ...counts, + } +} diff --git a/packages/core/src/tool/plugin/write.ts b/packages/core/src/tool/plugin/write.ts index ea8594813a..0d91d6db02 100644 --- a/packages/core/src/tool/plugin/write.ts +++ b/packages/core/src/tool/plugin/write.ts @@ -8,7 +8,9 @@ export * as WriteTool from "./write" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import { ToolFailure } from "@opencode-ai/ai" +import { FileDiff } from "@opencode-ai/schema/file-diff" import { Effect, Schema } from "effect" +import { createTwoFilesPatch, diffLines } from "diff" import { Bom } from "@opencode-ai/util/bom" import { FSUtil } from "@opencode-ai/util/fs-util" import { FileMutation } from "../../file-mutation" @@ -21,8 +23,7 @@ export const name = "write" // TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior. export const Input = Schema.Struct({ path: Schema.String.annotate({ - description: - "File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.", + description: "Path to the file to write to", }), content: Schema.String.annotate({ description: "Content to write to the file" }), }) @@ -59,7 +60,7 @@ export const Plugin = { name, options: { codemode: false, permission: "edit" }, description: - "Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", + "Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.", input: Input, output: Output, execute: (input, context) => @@ -78,10 +79,28 @@ export const Plugin = { agent: context.agent, source, }) + const current = yield* Bom.readFile(fs, target.canonical).pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)), + ) + const next = Bom.split(input.content) + const counts = diffLines(current?.text ?? "", next.text).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) + const preview: typeof FileDiff.Info.Type = { + file: target.resource, + patch: createTwoFilesPatch(target.resource, target.resource, current?.text ?? "", next.text), + status: current ? "modified" : "added", + ...counts, + } yield* permission.assert({ action: "edit", resources: [target.resource], save: ["*"], + metadata: { files: [preview] }, sessionID: context.sessionID, agent: context.agent, source, diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 3890497ebd..35269e37bf 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -179,6 +179,17 @@ describe("EditTool", () => { }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n") expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }]) + expect(assertions[0]?.metadata).toMatchObject({ + files: [ + { + file: "hello.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), + }, + ], + }) expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))]) }), ), @@ -343,7 +354,7 @@ describe("EditTool", () => { error: { type: "permission.rejected", message: "Permission denied: edit" }, }) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) - expect(reads).toBe(0) + expect(reads).toBe(1) expect(writes).toEqual([]) expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before") }), @@ -354,7 +365,7 @@ describe("EditTool", () => { ), ) - it.live("denied edit reads no target content and does not disclose whether oldString matches", () => + it.live("denied edit does not disclose whether oldString matches", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { @@ -380,7 +391,7 @@ describe("EditTool", () => { }) expect(missing).toEqual(matching) expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"]) - expect(reads).toBe(0) + expect(reads).toBe(2) expect(writes).toEqual([]) }), ), diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 0c7dd951e8..6acb9ca03d 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -140,6 +140,17 @@ describe("WriteTool", () => { "created", ) expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }]) + expect(assertions[0]?.metadata).toMatchObject({ + files: [ + { + file: "src/new.txt", + status: "added", + additions: 1, + deletions: 0, + patch: expect.stringContaining("+created"), + }, + ], + }) expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")]) }), ) @@ -187,6 +198,17 @@ describe("WriteTool", () => { if (settled.status !== "completed") return expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }]) expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true }) + expect(assertions[0]?.metadata).toMatchObject({ + files: [ + { + file: "existing.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringMatching(/-before[\s\S]*\+after/), + }, + ], + }) expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe( "after", ) From b1a5e8a6ae1374bc6ebb5a3a568800415bf762a3 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:11:58 -0500 Subject: [PATCH 44/51] test(tui): restore compaction event lifecycle (#39581) --- packages/tui/test/cli/tui/data.test.tsx | 27 +++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index d4deccbe6a..6f69efdb2e 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -1398,6 +1398,29 @@ test("restores queued compaction from durable pending input", async () => { { type: "compaction-queued", inputID: "message-compaction-later" }, ]) + emitEvent(events, { + id: "evt_step_started", + created: 2, + type: "session.step.started", + durable: durable(sessionID, 3), + data: { + sessionID, + assistantMessageID: "message-assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + emitEvent(events, { + id: "evt_text_started", + created: 2, + type: "session.text.started", + durable: durable(sessionID, 4), + data: { + sessionID, + assistantMessageID: "message-assistant", + ordinal: 0, + }, + }) emitEvent(events, { id: "evt_text_ended", created: 2, @@ -1417,7 +1440,7 @@ test("restores queued compaction from durable pending input", async () => { id: "evt_compaction_started", created: 2, type: "session.compaction.started", - durable: durable(sessionID, 4), + durable: durable(sessionID, 6), data: { sessionID, reason: "manual", @@ -1432,7 +1455,7 @@ test("restores queued compaction from durable pending input", async () => { id: "evt_compaction_ended", created: 3, type: "session.compaction.ended", - durable: durable(sessionID, 5), + durable: durable(sessionID, 7), data: { sessionID, reason: "manual", text: "Summary", recent: "" }, }) expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"]) From 97786afdd8475fdc1d02eb7f5b68d70d1a5576c7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:28:33 +0000 Subject: [PATCH 45/51] refactor(core): share file diff construction (#39586) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/core/src/tool/plugin/edit.ts | 18 +---------------- packages/core/src/tool/plugin/file-diff.ts | 23 ++++++++++++++++++++++ packages/core/src/tool/plugin/write.ts | 20 ++++++------------- 3 files changed, 30 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/tool/plugin/file-diff.ts diff --git a/packages/core/src/tool/plugin/edit.ts b/packages/core/src/tool/plugin/edit.ts index ccab3ff8eb..4fe50ed30a 100644 --- a/packages/core/src/tool/plugin/edit.ts +++ b/packages/core/src/tool/plugin/edit.ts @@ -10,13 +10,13 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin import { ToolFailure } from "@opencode-ai/ai" import { FileDiff } from "@opencode-ai/schema/file-diff" import { Bom } from "@opencode-ai/util/bom" -import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Schema } from "effect" import { FileMutation } from "../../file-mutation" import { Formatter } from "../../formatter" import { FSUtil } from "@opencode-ai/util/fs-util" import { LocationMutation } from "../../location-mutation" import { Permission } from "../../permission" +import { fileDiff } from "./file-diff" export const name = "edit" @@ -238,19 +238,3 @@ export const Plugin = { .pipe(Effect.orDie) }), } - -function fileDiff(file: string, before: string, after: string): typeof FileDiff.Info.Type { - const counts = diffLines(before, after).reduce( - (result, item) => ({ - additions: result.additions + (item.added ? (item.count ?? 0) : 0), - deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), - }), - { additions: 0, deletions: 0 }, - ) - return { - file, - patch: createTwoFilesPatch(file, file, before, after), - status: "modified", - ...counts, - } -} diff --git a/packages/core/src/tool/plugin/file-diff.ts b/packages/core/src/tool/plugin/file-diff.ts new file mode 100644 index 0000000000..250390c0f4 --- /dev/null +++ b/packages/core/src/tool/plugin/file-diff.ts @@ -0,0 +1,23 @@ +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { createTwoFilesPatch, diffLines } from "diff" + +export function fileDiff( + file: string, + before: string, + after: string, + status: typeof FileDiff.Info.Type.status = "modified", +): typeof FileDiff.Info.Type { + const counts = diffLines(before, after).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) + return { + file, + patch: createTwoFilesPatch(file, file, before, after), + status, + ...counts, + } +} diff --git a/packages/core/src/tool/plugin/write.ts b/packages/core/src/tool/plugin/write.ts index 0d91d6db02..332ade279e 100644 --- a/packages/core/src/tool/plugin/write.ts +++ b/packages/core/src/tool/plugin/write.ts @@ -8,15 +8,14 @@ export * as WriteTool from "./write" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import { ToolFailure } from "@opencode-ai/ai" -import { FileDiff } from "@opencode-ai/schema/file-diff" import { Effect, Schema } from "effect" -import { createTwoFilesPatch, diffLines } from "diff" import { Bom } from "@opencode-ai/util/bom" import { FSUtil } from "@opencode-ai/util/fs-util" import { FileMutation } from "../../file-mutation" import { Formatter } from "../../formatter" import { LocationMutation } from "../../location-mutation" import { Permission } from "../../permission" +import { fileDiff } from "./file-diff" export const name = "write" @@ -83,19 +82,12 @@ export const Plugin = { Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)), ) const next = Bom.split(input.content) - const counts = diffLines(current?.text ?? "", next.text).reduce( - (result, item) => ({ - additions: result.additions + (item.added ? (item.count ?? 0) : 0), - deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), - }), - { additions: 0, deletions: 0 }, + const preview = fileDiff( + target.resource, + current?.text ?? "", + next.text, + current ? "modified" : "added", ) - const preview: typeof FileDiff.Info.Type = { - file: target.resource, - patch: createTwoFilesPatch(target.resource, target.resource, current?.text ?? "", next.text), - status: current ? "modified" : "added", - ...counts, - } yield* permission.assert({ action: "edit", resources: [target.resource], From 488445a6794f02e2bc94035744998a81ab0abe56 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 21:58:53 -0400 Subject: [PATCH 46/51] fix(tui): correct project-aware session lists --- .../app/src/context/global-sync/bootstrap.ts | 2 +- packages/app/src/context/global-sync/utils.ts | 1 + packages/app/src/utils/server-compat.ts | 13 +- packages/cli/test/session-target.test.ts | 2 +- .../client/src/promise/generated/types.ts | 82 +++++----- packages/core/src/location.ts | 2 +- packages/core/src/project.ts | 55 ++++++- packages/core/src/session.ts | 33 ++-- .../test/effect/layer-node/node-build.test.ts | 4 +- packages/core/test/fixture/location.ts | 3 +- packages/core/test/location.test.ts | 2 + packages/core/test/move-session.test.ts | 21 --- packages/core/test/plugin/host.ts | 18 ++- packages/core/test/project.test.ts | 41 ++++- packages/core/test/session-compact.test.ts | 2 +- packages/core/test/session-create.test.ts | 24 ++- packages/core/test/session-generate.test.ts | 6 - .../core/test/session-instructions.test.ts | 2 +- packages/core/test/session-log.test.ts | 2 +- packages/core/test/session-remove.test.ts | 2 +- packages/core/test/session-skill.test.ts | 2 +- packages/core/test/session-wait.test.ts | 2 +- packages/plugin/src/tui/context.ts | 5 + packages/schema/src/location.ts | 1 + packages/schema/src/project.ts | 3 +- packages/server/src/handlers/project.ts | 6 +- .../tui/src/component/dialog-integration.tsx | 4 +- .../tui/src/component/dialog-move-session.tsx | 8 +- .../tui/src/component/dialog-session-list.tsx | 153 ++++++++++++------ packages/tui/src/component/dialog-skill.tsx | 8 +- packages/tui/src/context/data.tsx | 64 +++++--- packages/tui/src/routes/session/index.tsx | 2 +- packages/tui/src/ui/dialog-select.tsx | 4 +- packages/tui/test/cli/tui/data.test.tsx | 92 +++++++++-- .../tui/test/cli/tui/dialog-select.test.tsx | 13 ++ packages/tui/test/fixture/tui-client.ts | 20 +-- packages/tui/test/mini/runtime.test.ts | 16 +- .../tui/test/mini/stream-v2.transport.test.ts | 6 +- 38 files changed, 506 insertions(+), 220 deletions(-) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 39221d551f..844ce7cfc2 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -129,8 +129,8 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => api.list().then((projects) => { return projects .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .map(normalizeProjectInfo) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .slice() .sort((a, b) => cmp(a.id, b.id)) }), diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index 333bf094f0..0fd40ff2db 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -168,6 +168,7 @@ export function sanitizeProject(project: Project) { export function normalizeProjectInfo(project: Project | CurrentProject): Project { return { ...project, + worktree: "canonical" in project ? project.canonical : project.worktree, vcs: project.vcs === "git" ? "git" : undefined, } } diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index b37bf6db48..8a2c42f198 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -128,7 +128,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi { const located = (data: T, value?: { directory?: string }) => ({ location: { directory: directory(value) ?? "", - project: { id: "", directory: directory(value) ?? "" }, + project: { id: "", directory: directory(value) ?? "", canonical: directory(value) ?? "" }, }, data, }) @@ -298,12 +298,19 @@ function createV1Api(input: CompatibleInput): CompatibleApi { project: { ...input.current.project, async list() { - return ((await legacy().project.list()).data ?? []) as Project[] + return ((await legacy().project.list()).data ?? []).map((project) => ({ + ...project, + canonical: project.worktree, + })) }, async current(value?: Parameters[0]) { const result = await legacy(value?.location).project.current() if (!result.data) throw new Error("Project not found") - return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent + return { + id: result.data.id, + directory: result.data.worktree, + canonical: result.data.worktree, + } satisfies ProjectCurrent }, // async update(value: Parameters[0]) { // const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID) diff --git a/packages/cli/test/session-target.test.ts b/packages/cli/test/session-target.test.ts index ea34aada7f..0a5bf8c55d 100644 --- a/packages/cli/test/session-target.test.ts +++ b/packages/cli/test/session-target.test.ts @@ -3,7 +3,7 @@ import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } fro import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target" function location(directory: string, workspaceID?: string): LocationGetOutput { - return { directory, workspaceID, project: { id: "project", directory } } + return { directory, workspaceID, project: { id: "project", directory, canonical: directory } } } function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 9868099438..8c8b6295a6 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -279,7 +279,7 @@ export type ProjectCommands = { start?: string } export type ProjectTime = { created: number; updated: number; initialized?: number } -export type ProjectCurrent = { id: string; directory: string } +export type ProjectCurrent = { id: string; directory: string; canonical: string } export type ProjectDirectory = { directory: string; strategy?: string } @@ -1430,7 +1430,7 @@ export type McpResourceCatalog = { resources: Array; templates: Arr export type Project = { id: string - worktree: string + canonical: string vcs?: ProjectVcs name?: string icon?: ProjectIcon @@ -2515,7 +2515,11 @@ export type LocationGetInput = { }["location"] } -export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } } +export type LocationGetOutput = { + directory: string + workspaceID?: string + project: { id: string; directory: string; canonical: string } +} export type AgentListInput = { readonly location?: { @@ -2524,7 +2528,7 @@ export type AgentListInput = { } export type AgentListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -2536,7 +2540,7 @@ export type AgentGetInput = { } export type AgentGetOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: AgentInfo } @@ -2547,7 +2551,7 @@ export type PluginListInput = { } export type PluginListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -3231,7 +3235,7 @@ export type ModelListInput = { } export type ModelListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -3242,7 +3246,7 @@ export type ModelDefaultInput = { } export type ModelDefaultOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: ModelInfo | null } @@ -3269,7 +3273,7 @@ export type ProviderListInput = { } export type ProviderListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -3281,7 +3285,7 @@ export type ProviderGetInput = { } export type ProviderGetOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: ProviderInfo } @@ -3292,7 +3296,7 @@ export type IntegrationListInput = { } export type IntegrationListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -3304,7 +3308,7 @@ export type IntegrationGetInput = { } export type IntegrationGetOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: IntegrationInfo | null } @@ -3351,7 +3355,7 @@ export type IntegrationOauthConnectInput = { } export type IntegrationOauthConnectOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: { attemptID: string url: string @@ -3370,7 +3374,7 @@ export type IntegrationOauthStatusInput = { } export type IntegrationOauthStatusOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: IntegrationAttemptStatus } @@ -3405,7 +3409,7 @@ export type IntegrationCommandConnectInput = { } export type IntegrationCommandConnectOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: IntegrationCommandAttempt } @@ -3418,7 +3422,7 @@ export type IntegrationCommandStatusInput = { } export type IntegrationCommandStatusOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: IntegrationCommandAttemptStatus } @@ -3439,7 +3443,7 @@ export type McpListInput = { } export type McpListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -3528,7 +3532,7 @@ export type McpResourceCatalogInput = { } export type McpResourceCatalogOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: McpResourceCatalog } @@ -3577,7 +3581,7 @@ export type FormRequestListInput = { } export type FormRequestListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4433,7 +4437,7 @@ export type PermissionRequestListInput = { } export type PermissionRequestListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4555,7 +4559,7 @@ export type FileListInput = { } export type FileListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4587,7 +4591,7 @@ export type FileFindInput = { } export type FileFindOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4598,7 +4602,7 @@ export type CommandListInput = { } export type CommandListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4609,7 +4613,7 @@ export type SkillListInput = { } export type SkillListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4622,7 +4626,7 @@ export type PtyListInput = { } export type PtyListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4668,7 +4672,7 @@ export type PtyCreateInput = { } export type PtyCreateOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Pty } @@ -4680,7 +4684,7 @@ export type PtyGetInput = { } export type PtyGetOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Pty } @@ -4697,7 +4701,7 @@ export type PtyUpdateInput = { } export type PtyUpdateOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Pty } @@ -4717,7 +4721,7 @@ export type ShellListInput = { } export type ShellListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4752,7 +4756,7 @@ export type ShellCreateInput = { } export type ShellCreateOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: ShellInfo1 } @@ -4764,7 +4768,7 @@ export type ShellGetInput = { } export type ShellGetOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: ShellInfo1 } @@ -4777,7 +4781,7 @@ export type ShellTimeoutInput = { } export type ShellTimeoutOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: ShellInfo1 } @@ -4801,7 +4805,7 @@ export type ShellOutputInput = { } export type ShellOutputOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: { output: string; cursor: number; size: number; truncated: boolean } } @@ -4821,7 +4825,7 @@ export type QuestionRequestListInput = { } export type QuestionRequestListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4851,7 +4855,7 @@ export type ReferenceListInput = { } export type ReferenceListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4894,7 +4898,7 @@ export type VcsStatusInput = { } export type VcsStatusOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4917,7 +4921,7 @@ export type VcsDiffInput = { } export type VcsDiffOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4938,7 +4942,7 @@ export type WebsearchProvidersInput = { } export type WebsearchProvidersOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4951,6 +4955,6 @@ export type WebsearchQueryInput = { } export type WebsearchQueryOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: { providerID: string; results: Array } } diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index f5ccabf8c5..ac27cb6ca9 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -25,7 +25,7 @@ const layer = (ref: Ref) => return Service.of({ directory: ref.directory, workspaceID: ref.workspaceID, - project: { id: resolved.id, directory: resolved.directory }, + project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical }, vcs: resolved.vcs, }) }), diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 2fb0aabc18..ba907367ad 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -2,7 +2,7 @@ export * as Project from "./project" import { Context, Effect, Layer, Schema } from "effect" import { ChildProcess } from "effect/unstable/process" -import { asc, desc } from "drizzle-orm" +import { asc, desc, isNotNull, isNull, ne, or } from "drizzle-orm" import path from "path" import { AbsolutePath } from "./schema" import { Database } from "./database/database" @@ -40,6 +40,7 @@ export interface Resolved { readonly previous?: ID readonly id: ID readonly directory: AbsolutePath + readonly canonical: AbsolutePath readonly vcs?: Vcs } @@ -83,7 +84,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info { : undefined return { id: row.id, - worktree: row.worktree, + canonical: row.worktree, vcs: row.vcs ?? undefined, name: row.name ?? undefined, icon, @@ -106,6 +107,40 @@ const layer = Layer.effect( const db = (yield* Database.Service).db const projectDirectories = yield* ProjectDirectories.Service + const persist = Effect.fnUntraced(function* (project: Resolved) { + yield* db + .transaction((tx) => + Effect.gen(function* () { + const vcs = project.vcs?.type + yield* tx + .insert(ProjectTable) + .values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] }) + .onConflictDoUpdate({ + target: ProjectTable.id, + set: { worktree: project.canonical, vcs: vcs ?? null }, + setWhere: or( + ne(ProjectTable.worktree, project.canonical), + vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs), + ), + }) + .run() + if (!project.vcs) return + yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx) + if (project.directory === project.canonical) return + yield* projectDirectories.create( + { + projectID: project.id, + directory: project.directory, + strategy: project.vcs.type === "git" ? "git_worktree" : undefined, + }, + tx, + ) + }), + ) + .pipe(Effect.orDie) + return project + }) + const list = Effect.fn("Project.list")(function* () { const rows = yield* db .select() @@ -211,17 +246,25 @@ const layer = Layer.effect( if (repo) { const previous = yield* cached(repo.commonDirectory) const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) - return { + const canonical = yield* git.worktree + .list(repo) + .pipe( + Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree), + Effect.catch(() => Effect.succeed(repo.worktree)), + ) + return yield* persist({ previous, id: id ?? ID.global, directory: repo.worktree, + canonical, vcs: { type: "git" as const, store: repo.commonDirectory }, - } + }) } const hg = yield* hgDiscover(input) - if (hg) return hg - return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined } + if (hg) return yield* persist({ ...hg, canonical: hg.directory }) + const directory = AbsolutePath.make(path.parse(input).root) + return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined }) }) const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 0ac1b5cc62..bf1b38eaaf 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -3,7 +3,7 @@ export * from "./session/schema" import { Effect, Layer, Schema, Context, Stream, Scope } from "effect" import { ListAnchor } from "@opencode-ai/schema/session" -import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm" +import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm" import { Project } from "./project" import { Workspace } from "./workspace" import { Model } from "./model" @@ -325,6 +325,22 @@ const layer = Layer.effect( const shellLocks = KeyedMutex.makeUnsafe() const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) + const persistProject = (project: Project.Resolved) => { + const vcs = project.vcs?.type + return db + .insert(ProjectTable) + .values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] }) + .onConflictDoUpdate({ + target: ProjectTable.id, + set: { worktree: project.canonical, vcs: vcs ?? null }, + setWhere: or( + ne(ProjectTable.worktree, project.canonical), + vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs), + ), + }) + .run() + .pipe(Effect.orDie) + } const decode = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( Effect.mapError( @@ -347,12 +363,7 @@ const layer = Layer.effect( if (location === undefined) return yield* Effect.die(new Error("Session.create requires either location or an existing parentID")) const project = yield* projects.resolve(location.directory) - yield* db - .insert(ProjectTable) - .values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] }) - .onConflictDoNothing() - .run() - .pipe(Effect.orDie) + yield* persistProject(project) const now = Date.now() const info = SessionV1.SessionInfo.make({ id: sessionID, @@ -451,6 +462,7 @@ const layer = Layer.effect( if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) + if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath)) if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) if (input.parentID !== undefined) conditions.push( @@ -732,12 +744,7 @@ const layer = Layer.effect( ) return const project = yield* projects.resolve(directory) - yield* db - .insert(ProjectTable) - .values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] }) - .onConflictDoNothing() - .run() - .pipe(Effect.orDie) + yield* persistProject(project) if ((yield* execution.active).has(input.sessionID)) { yield* execution.interrupt(input.sessionID) yield* execution.awaitIdle(input.sessionID) diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index a4a2bf2860..0c7cc489f4 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -56,7 +56,7 @@ describe("node build", () => { Location.Service.of({ directory: ref.directory, workspaceID: ref.workspaceID, - project: { id: Project.ID.global, directory: service.directory }, + project: { id: Project.ID.global, directory: service.directory, canonical: service.directory }, }), ), { idleTimeToLive: "1 minute" }, @@ -79,7 +79,7 @@ describe("node build", () => { return Project.Service.of({ list: () => Effect.succeed([]), directories: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), commit: () => Effect.void, }) }), diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts index 40d8ed9dc3..d82d439943 100644 --- a/packages/core/test/fixture/location.ts +++ b/packages/core/test/fixture/location.ts @@ -5,10 +5,11 @@ import { Effect, Layer } from "effect" import { tmpdir } from "./tmpdir" export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { + const directory = input.projectDirectory ?? ref.directory return { directory: ref.directory, workspaceID: ref.workspaceID, - project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory }, + project: { id: Project.ID.global, directory, canonical: directory }, vcs: input.vcs, } satisfies Location.Interface } diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index b51c3c5bd8..a37234feca 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -18,6 +18,7 @@ const projectLayer = Layer.succeed( Effect.succeed({ id: Project.ID.make("project"), directory: AbsolutePath.make("/repo"), + canonical: AbsolutePath.make("/main/repo"), vcs: { type: "git", store: AbsolutePath.make("/repo/.git") }, }), commit: () => Effect.void, @@ -34,6 +35,7 @@ describe("Location", () => { expect(location.workspaceID).toBe(workspaceID) expect(location.project.id).toBe(Project.ID.make("project")) expect(location.project.directory).toBe(AbsolutePath.make("/repo")) + expect(location.project.canonical).toBe(AbsolutePath.make("/main/repo")) expect(location.vcs).toEqual({ type: "git", store: AbsolutePath.make("/repo/.git"), diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 9a37f143b1..f97b0cfb86 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -11,7 +11,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" import { Job } from "@opencode-ai/core/job" import { Project } from "@opencode-ai/core/project" -import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" @@ -88,11 +87,6 @@ describe("MoveSession", () => { const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id const sessionID = Session.ID.make("ses_move") const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) - .run() - .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ @@ -144,11 +138,6 @@ describe("MoveSession", () => { const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id const sessionID = Session.ID.make("ses_move_nested") const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) - .run() - .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ @@ -204,11 +193,6 @@ describe("MoveSession", () => { const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id const sessionID = Session.ID.make("ses_move_project") const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) - .run() - .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ @@ -268,11 +252,6 @@ describe("MoveSession", () => { const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id const sessionID = Session.ID.make("ses_move_nested_checkout") const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) - .run() - .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 956c5353d2..975845f450 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -121,7 +121,11 @@ export function agentHost(agent: Agent.Interface): Plugin.Context["agent"] { ? Effect.succeed({ location: new Location.Info({ directory: AbsolutePath.make("/"), - project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") }, + project: { + id: Project.ID.make("test"), + directory: AbsolutePath.make("/"), + canonical: AbsolutePath.make("/"), + }, }), data: agentInfo(value), }) @@ -163,7 +167,11 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog Effect.map((data) => ({ location: new Location.Info({ directory: AbsolutePath.make("/"), - project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") }, + project: { + id: Project.ID.make("test"), + directory: AbsolutePath.make("/"), + canonical: AbsolutePath.make("/"), + }, }), data: data.map(modelInfo), })), @@ -357,7 +365,11 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] { const location = Location.Info.make({ directory: AbsolutePath.make("/tmp/websearch-test"), - project: { id: Project.ID.make("websearch-test"), directory: AbsolutePath.make("/tmp/websearch-test") }, + project: { + id: Project.ID.make("websearch-test"), + directory: AbsolutePath.make("/tmp/websearch-test"), + canonical: AbsolutePath.make("/tmp/websearch-test"), + }, }) return { providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))), diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 44d36fd85f..7be0a5ad77 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -47,13 +47,13 @@ describe("Project.list", () => { expect(yield* project.list()).toEqual([ { id: Project.ID.make("newer"), - worktree: abs("/newer"), + canonical: abs("/newer"), time: { created: 2, updated: 2, initialized: 3 }, sandboxes: [], }, { id: Project.ID.make("older"), - worktree: abs("/older"), + canonical: abs("/older"), vcs: "git", name: "Older", icon: { color: "#000000" }, @@ -105,6 +105,7 @@ describe("Project.resolve", () => { expect(result.id).toBe(Project.ID.make("global")) expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root) + expect(result.canonical).toBe(result.directory) expect(result.previous).toBeUndefined() expect(result.vcs).toBeUndefined() }), @@ -123,6 +124,7 @@ describe("Project.resolve", () => { expect(result.id).toBe(Project.ID.make("global")) expect(result.directory).toBe(yield* real(tmp.path)) + expect(result.canonical).toBe(result.directory) expect(result.previous).toBeUndefined() expect(result.vcs?.type).toBe("git") }), @@ -327,13 +329,46 @@ describe("Project.resolve", () => { yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id")) yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet()) const project = yield* Project.Service + const db = (yield* Database.Service).db + const id = remoteID("github.com/owner/repo") + yield* db + .insert(ProjectTable) + .values({ + id, + worktree: abs("/stale-worktree"), + vcs: "hg", + name: "Preserved name", + icon_color: "#123456", + commands: { start: "bun dev" }, + sandboxes: [abs("/preserved-sandbox")], + time_created: 1, + time_updated: 1, + time_initialized: 2, + }) + .run() const result = yield* project.resolve(abs(worktree)) expect(result.directory).toBe(yield* real(worktree)) + expect(result.canonical).toBe(yield* real(tmp.path)) expect(result.previous).toBe(Project.ID.make("old-id")) - expect(result.id).toBe(remoteID("github.com/owner/repo")) + expect(result.id).toBe(id) expect(result.vcs?.type).toBe("git") + expect((yield* project.list()).find((item) => item.id === id)).toMatchObject({ + canonical: yield* real(tmp.path), + vcs: "git", + name: "Preserved name", + icon: { color: "#123456" }, + commands: { start: "bun dev" }, + sandboxes: [abs("/preserved-sandbox")], + time: { created: 1, initialized: 2 }, + }) + expect( + (yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)), + ).toEqual([ + { directory: yield* real(tmp.path) }, + { directory: yield* real(worktree), strategy: "git_worktree" }, + ]) }), ) }) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index a64f2367d5..61bb24a622 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -34,7 +34,7 @@ const projects = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, }), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 1bda9a5488..ceffdcf91d 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -14,7 +14,7 @@ import { Model } from "@opencode-ai/core/model" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { Provider } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" +import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" import { SessionV1 } from "@opencode-ai/core/v1/session" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -32,7 +32,7 @@ const projects = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, }), @@ -184,6 +184,26 @@ describe("Session.create", () => { }), ) + it.effect("filters project sessions by subpath", () => + Effect.gen(function* () { + const session = yield* Session.Service + const { db } = yield* Database.Service + const root = yield* session.create({ location, title: "root" }) + const nested = yield* session.create({ location, title: "nested" }) + + yield* db.update(SessionTable).set({ path: "packages/tui" }).where(eq(SessionTable.id, nested.id)).run() + + const page = yield* session.list({ + project: Project.ID.global, + subpath: RelativePath.make("packages/tui"), + parentID: null, + }) + + expect(page.data.map((item) => item.id)).toEqual([nested.id]) + expect(page.data.map((item) => item.id)).not.toContain(root.id) + }), + ) + it.effect("forks a session by replaying a durable fork event into copied projected rows", () => Effect.gen(function* () { const session = yield* Session.Service diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 7ca2a9b18f..9a0d1efa3f 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -15,7 +15,6 @@ import { Location } from "@opencode-ai/core/location" import { McpInstructions } from "@opencode-ai/core/mcp/instructions" import { ID } from "@opencode-ai/core/model" import { Project } from "@opencode-ai/core/project" -import { ProjectTable } from "@opencode-ai/core/project/sql" import { Provider } from "@opencode-ai/core/provider" import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -191,11 +190,6 @@ const setup = Effect.gen(function* () { agent.mode = "primary" }), ) - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index 1ccfec2068..fd4e00249c 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -54,7 +54,7 @@ const projects = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, }), diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index f223542caf..fa3961f212 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -21,7 +21,7 @@ const projects = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, }), diff --git a/packages/core/test/session-remove.test.ts b/packages/core/test/session-remove.test.ts index 65728a7b7c..cf3ce126cc 100644 --- a/packages/core/test/session-remove.test.ts +++ b/packages/core/test/session-remove.test.ts @@ -17,7 +17,7 @@ const projects = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, }), diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 2ab4a65551..7461f9f2c1 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -20,7 +20,7 @@ import { testEffect } from "./lib/effect" const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const projects = Layer.mock(Project.Service, { - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), }) const skills = Layer.mock(Skill.Service, { list: () => diff --git a/packages/core/test/session-wait.test.ts b/packages/core/test/session-wait.test.ts index b3861a29c3..9fa8d1c682 100644 --- a/packages/core/test/session-wait.test.ts +++ b/packages/core/test/session-wait.test.ts @@ -17,7 +17,7 @@ import { testEffect } from "./lib/effect" const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const awaited: Session.ID[] = [] const projects = Layer.mock(Project.Service, { - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), }) const execution = Layer.mock(SessionExecution.Service, { awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)), diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 2bc5d393ca..374e56aabe 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -11,6 +11,7 @@ import type { OpenCodeEvent, PermissionSavedInfo, PermissionRequest, + Project, ProviderInfo, ReferenceInfo, SessionInfo, @@ -77,6 +78,10 @@ export interface Data { } } readonly project: { + list(): Project[] + get(projectID: string): Project | undefined + sync(): Promise + invalidate(): void readonly permission: { list(projectID: string): PermissionSavedInfo[] | undefined sync(projectID: string): Promise diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts index 30a3e34021..d9104435c6 100644 --- a/packages/schema/src/location.ts +++ b/packages/schema/src/location.ts @@ -17,6 +17,7 @@ export class Info extends Schema.Class("Location.Info")({ project: Schema.Struct({ id: ProjectID, directory: AbsolutePath, + canonical: AbsolutePath, }), }) {} diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index e8524300a9..e47e45678c 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -12,6 +12,7 @@ export const Vcs = Schema.Literals(["git", "hg"]).annotate({ identifier: "Projec export const Current = Schema.Struct({ id: ID, directory: AbsolutePath, + canonical: AbsolutePath, }).annotate({ identifier: "Project.Current" }) export interface Current extends Schema.Schema.Type {} export const Directory = Schema.Struct({ @@ -46,7 +47,7 @@ export interface Time extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, - worktree: Schema.String, + canonical: AbsolutePath, vcs: optional(Vcs), name: optional(Schema.String), icon: optional(Icon), diff --git a/packages/server/src/handlers/project.ts b/packages/server/src/handlers/project.ts index 79206e6326..e02aa814aa 100644 --- a/packages/server/src/handlers/project.ts +++ b/packages/server/src/handlers/project.ts @@ -9,7 +9,11 @@ export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handl .handle("project.list", () => Project.Service.use((project) => project.list())) .handle("project.current", () => Location.Service.use((location) => - Effect.succeed({ id: location.project.id, directory: location.project.directory }), + Effect.succeed({ + id: location.project.id, + directory: location.project.directory, + canonical: location.project.canonical, + }), ), ) .handle("project.directories", (ctx) => diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index f07d6f27cb..6511a14036 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -102,12 +102,12 @@ export function DialogIntegration( title="Connect a service" options={options()} emptyView={ - + No integrations available } noMatchView={ - + No integrations found } diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 0ca6a1168c..af9d8209f5 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -328,7 +328,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { options={options()} emptyView={ showError() ? ( - + Could not load project directories @@ -336,17 +336,17 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { Close and reopen Move session to try again. ) : directories.loading || loadedProject.loading ? ( - + Loading project directories… ) : ( - + No project directories available ) } noMatchView={ - + No project directories found } diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 3b489607b1..ebe93e34fd 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -1,6 +1,7 @@ -import { createMemo, createResource, createSignal, onMount } from "solid-js" +import { createMemo, createResource, createSignal, onMount, Show } from "solid-js" import path from "path" import type { SessionInfo } from "@opencode-ai/client" +import { TextAttributes } from "@opentui/core" import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useRoute } from "../context/route" @@ -32,52 +33,69 @@ export function DialogSessionList() { const shortcuts = Keymap.useShortcuts() const [search, setSearch] = createDebouncedSignal("", 150) const [toDelete, setToDelete] = createSignal() + const [allProjects, setAllProjects] = createSignal(false) - const [searchResults] = createResource(search, async (query) => { - if (!query) return - try { - if (!data.location.info()) await data.location.sync() - const current = data.location.info() - if (!current) throw new Error("Location unavailable") - const response = await client.api.session.list({ - project: current.project.id, - search: query, - limit: 50, - order: "desc", - parentID: null, - }) - return { query, sessions: response.data, error: undefined } - } catch (error) { - // A transient transport failure must degrade search, not crash the TUI - // through the root ErrorBoundary when the errored resource is read. - return { query, sessions: [] as SessionInfo[], error } - } - }) + const [searchResults, { mutate: setSearchResults }] = createResource( + () => ({ query: search().trim(), allProjects: allProjects() }), + async ({ query, allProjects }) => { + try { + if (!data.location.info()) await data.location.sync() + const current = data.location.info() + if (!current) throw new Error("Location unavailable") + const response = await client.api.session.list({ + ...(allProjects + ? {} + : { + project: current.project.id, + subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"), + }), + ...(query ? { search: query } : {}), + limit: 50, + order: "desc", + parentID: null, + }) + return { query, allProjects, sessions: response.data, error: undefined } + } catch (error) { + // A transient transport failure must degrade search, not crash the TUI + // through the root ErrorBoundary when the errored resource is read. + return { query, allProjects, sessions: [] as SessionInfo[], error } + } + }, + ) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const localSessions = createMemo(() => { const query = filter().trim().toLowerCase() - const sessions = data.session.list() + const current = data.location.info() + const sessions = data.session + .list() + .filter( + (session) => + allProjects() || + (session.projectID === current?.project.id && session.location.directory === current.directory), + ) if (!query) return sessions return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query)) }) const sessions = createMemo(() => { - const query = filter() + const query = filter().trim() const local = localSessions() - if (!query) return local - if (query !== search() || searchResults.loading) return local + if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local const result = searchResults() - if (result?.query !== query || result.error) return local + if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local return result.sessions }) const searchState = createMemo(() => { - const query = filter() - if (!query) return { message: "No sessions available", error: false } - if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false } + const query = filter().trim() + if (query !== search().trim() || searchResults.loading) + return { message: query ? "Searching sessions…" : "Loading sessions…", error: false } const result = searchResults() if (result?.query === query && result.error) - return { message: "Could not search sessions. Change the search to try again.", error: true } - return { message: "No sessions found", error: false } + return { + message: query ? "Could not search sessions. Change the search to try again." : "Could not load sessions.", + error: true, + } + return { message: query ? "No sessions found" : "No sessions available", error: false } }) const quickSwitchHint = createMemo(() => { @@ -91,6 +109,13 @@ export function DialogSessionList() { const hint = quickSwitchHint() return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : [] }) + const currentProjectName = createMemo(() => { + const current = data.location.info() + if (!current) return "" + const project = data.project.get(current.project.id) + if (!project) return "" + return project.name || path.basename(project.canonical) + }) const options = createMemo(() => { const today = new Date().toDateString() @@ -105,8 +130,12 @@ export function DialogSessionList() { const option = (session: SessionInfo, category: string) => { const directory = session.location.directory - const footer = - directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : "" + const project = data.project.get(session.projectID) + const footer = allProjects() + ? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20) + : directory !== data.location.info()?.project.directory + ? Locale.truncate(path.basename(directory), 20) + : "" const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id) const deleting = toDelete() === session.id return { @@ -139,6 +168,16 @@ export function DialogSessionList() { return ( + + Sessions + + + for {currentProjectName()} + + + } options={options()} skipFilter={true} current={currentSessionID()} @@ -146,13 +185,25 @@ export function DialogSessionList() { setFilter(query) setSearch(query) }} + bindings={[ + { + bind: "ctrl+a", + title: allProjects() ? "Show current directory sessions" : "Show all project sessions", + group: "Dialog", + run: () => { + setAllProjects((value) => !value) + }, + }, + ]} emptyView={ - - No sessions available + + + {searchState().message} + } noMatchView={ - + {searchState().message} @@ -178,24 +229,34 @@ export function DialogSessionList() { setToDelete(option.value) return } - void client.api.session.remove({ sessionID: option.value }).catch((error) => { - setToDelete(undefined) - toast.show({ - message: `Failed to delete session: ${errorMessage(error)}`, - variant: "error", - duration: 5000, + void client.api.session + .remove({ sessionID: option.value }) + .then(() => { + setSearchResults((result) => + result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result, + ) + }) + .catch((error) => { + setToDelete(undefined) + toast.show({ + message: `Failed to delete session: ${errorMessage(error)}`, + variant: "error", + duration: 5000, + }) }) - }) }, }, { command: "session.rename", title: "rename", - onTrigger: (option: { value: string }) => - DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title), + onTrigger: (option: { value: string; title: string }) => + DialogSessionRename.show(dialog, option.value, option.title), }, ]} - footerHints={quickSwitchFooterHints()} + footerHints={[ + ...quickSwitchFooterHints(), + { title: allProjects() ? "current directory" : "all projects", label: "ctrl+a", side: "right" }, + ]} /> ) } diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index 3d2e31260c..bbcd62cf41 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -62,13 +62,13 @@ export function DialogSkill(props: DialogSkillProps) { emptyView={ + No skills available } > - + Could not load skills @@ -77,14 +77,14 @@ export function DialogSkill(props: DialogSkillProps) { - + Loading skills… } noMatchView={ - + No skills found } diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 5880275790..384fd3499c 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -15,6 +15,7 @@ import type { ModelInfo, PermissionSavedInfo, PermissionRequest, + Project, ProviderInfo, ReferenceInfo, SessionMessageInfo, @@ -80,6 +81,7 @@ type Store = { form: Record } project: { + info: Record permission: Record } location: Record @@ -139,6 +141,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ form: {}, }, project: { + info: {}, permission: {}, }, location: {}, @@ -954,10 +957,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ sync.invalidate(`session.pending:${sessionID}`) }, }, - sync(sessionID: string) { - return sync.run(`session:${sessionID}`, async () => { - setStore("session", "info", sessionID, await client.api.session.get({ sessionID })) - registerSession(sessionID) + sync(sessionID: string, options?: { children?: boolean }) { + return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => { + const [info, children] = await Promise.all([ + client.api.session.get({ sessionID }), + options?.children + ? client.api.session.list({ parentID: sessionID, order: "desc" }).then((response) => response.data) + : [], + ]) + const sessions = [info, ...children] + setStore( + "session", + "info", + produce((draft) => { + for (const session of sessions) draft[session.id] = session + }), + ) + for (const session of sessions) { + sync.complete(`session:${session.id}`) + registerSession(session.id) + } }) }, invalidate(sessionID: string) { @@ -1037,6 +1056,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, project: { + list() { + return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated) + }, + get(projectID: string) { + return store.project.info[projectID] + }, + sync() { + return sync.run("project", async () => { + const projects = await client.api.project.list() + setStore("project", "info", reconcile(Object.fromEntries(projects.map((project) => [project.id, project])))) + }) + }, + invalidate() { + sync.invalidate("project") + }, permission: { list(projectID: string) { return store.project.permission[projectID] @@ -1318,27 +1352,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ .then((location) => { const key = locationKey(location) setStore("location", key, { ...store.location[key], info: location }) - return client.api.session.list({ - project: location.project.id, - limit: 50, - order: "desc", - parentID: null, - }) }) - .then((response) => { - setStore( - "session", - "info", - produce((draft) => { - for (const session of response.data) draft[session.id] = session - }), - ) - for (const session of response.data) { - sync.complete(`session:${session.id}`) - registerSession(session.id) - } - }) - .catch((error) => console.error("Failed to preload sessions", error)) + .catch((error) => console.error("Failed to preload location", error)) + void result.project.sync().catch((error) => console.error("Failed to preload projects", error)) return } handleEvent(details) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 0f3ade0541..eb09929ad3 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -256,7 +256,7 @@ export function Session() { const sessionID = route.sessionID void (async () => { await Promise.all([ - data.session.sync(sessionID), + data.session.sync(sessionID, { children: true }), data.session.permission.sync(sessionID), data.session.form.sync(sessionID), ]) diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index c10e44ad2f..6764b07454 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -615,14 +615,14 @@ export function DialogSelect(props: DialogSelectProps) { when={props.renderFilter !== false && store.filter.length > 0} fallback={ props.emptyView ?? ( - + No items available ) } > {props.noMatchView ?? ( - + No results found )} diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 6f69efdb2e..c814f57679 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -11,7 +11,7 @@ import { ClientProvider, useClient } from "../../../src/context/client" import { DataProvider as DataProviderBase, useData } from "../../../src/context/data" import { LocationProvider, useLocation } from "../../../src/context/location" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" -import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client" +import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client" import { TestTuiContexts } from "../../fixture/tui-environment" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" @@ -71,11 +71,13 @@ function durable(sessionID: string, seq = 0, version = 1) { return { aggregateID: sessionID, seq, version } } -test("preloads root sessions before applying the session limit", async () => { +test("does not preload session summaries into the data context", async () => { const events = createEventStream() - let request: URL | undefined + let location = false + let sessions = false const calls = createFetch((url) => { - if (url.pathname === "/api/session") request = url + if (url.pathname === "/api/location") location = true + if (url.pathname === "/api/session") sessions = true return undefined }, events) @@ -92,10 +94,58 @@ test("preloads root sessions before applying the session limit", async () => { )) try { - await wait(() => request !== undefined) - expect(request?.searchParams.get("project")).toBe("proj_test") - expect(request?.searchParams.get("limit")).toBe("50") - expect(request?.searchParams.get("parentID")).toBe("null") + await wait(() => location) + await Bun.sleep(20) + expect(sessions).toBe(false) + } finally { + app.renderer.destroy() + } +}) + +test("proactively syncs project metadata", async () => { + const events = createEventStream() + const calls = createFetch((url) => { + if (url.pathname !== "/api/project") return + return json([ + { + id: "proj_test", + canonical: worktree, + name: "OpenCode", + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + ]) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.project.get("proj_test") !== undefined) + expect(data.project.list()).toEqual([ + { + id: "proj_test", + canonical: worktree, + name: "OpenCode", + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + ]) } finally { app.renderer.destroy() } @@ -2619,8 +2669,18 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) { // the family-index tests below. async function mountData(parents: Record, costs: Record = {}) { const calls = createFetch((url) => { + if (url.pathname === "/api/session") { + const parentID = url.searchParams.get("parentID") + return json({ + data: Object.entries(parents) + .filter(([, parent]) => parent === parentID) + .map(([id, parent]) => sessionInfo(id, parent, costs[id])), + cursor: {}, + }) + } const match = url.pathname.match(/^\/api\/session\/([^/]+)$/) - if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) }) + if (match && match[1] !== "active") + return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) }) }) let data!: ReturnType let ready!: () => void @@ -2647,6 +2707,20 @@ async function mountData(parents: Record, costs: Record { + const { data, app } = await mountData({ child: "root", sibling: "root", grandchild: "child" }) + try { + await data.session.sync("root", { children: true }) + expect(data.session.get("root")?.id).toBe("root") + expect(data.session.get("child")?.parentID).toBe("root") + expect(data.session.get("sibling")?.parentID).toBe("root") + expect(data.session.get("grandchild")).toBeUndefined() + expect(data.session.family("root")).toEqual(["root", "child", "sibling"]) + } finally { + app.renderer.destroy() + } +}) + test("groups an orphan child under its missing parent until the root arrives", async () => { const { data, app } = await mountData({ child: "root" }) try { diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx index 78984ee5bc..ebf7720bef 100644 --- a/packages/tui/test/cli/tui/dialog-select.test.tsx +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -185,6 +185,19 @@ test("dialog actions run without options while row actions still require a selec } }) +test("renders one gap before an empty state", async () => { + await using tmp = await tmpdir() + const app = await renderSelect(tmp.path, [], () => {}, () => {}) + + try { + await app.waitForFrame((frame) => frame.includes("No items available")) + const lines = app.captureCharFrame().split("\n").map((line) => line.trim()) + expect(lines.indexOf("No items available") - lines.indexOf("Search")).toBe(2) + } finally { + app.renderer.destroy() + } +}) + test("footer actions run when filtering leaves no selected row", async () => { await using tmp = await tmpdir() let global = 0 diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index 5cf10b8ca6..eae2d142da 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -93,24 +93,26 @@ export function createFetch(override?: FetchHandler, events?: ReturnType { directory: "/tmp", target: async () => ({ sessionID: "ses_root", - location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, agent: "build", model: undefined, variant: undefined, @@ -130,7 +130,7 @@ describe("run interactive runtime", () => { await refreshCatalog?.() expect(defaultModel).toHaveBeenCalledTimes(1) selected.resolve({ - location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, data: model, }) while (defaultModel.mock.calls.length < 2) await Bun.sleep(0) @@ -165,7 +165,7 @@ describe("run interactive runtime", () => { directory: "/tmp", target: async () => ({ sessionID: "ses_root", - location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, agent: "build", model: { providerID: "test", modelID: "model" }, variant: undefined, @@ -261,7 +261,7 @@ describe("run interactive runtime", () => { return { sessionID: "ses-deferred", sessionTitle: "Deferred", - location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, agent: "build", model: { providerID: "openai", modelID: "gpt-5" }, variant: undefined, @@ -349,7 +349,7 @@ describe("run interactive runtime", () => { target: async () => ({ sessionID: "ses-resume", sessionTitle: "Resume", - location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, agent: "review", model: { providerID: "openai", modelID: "gpt-5" }, variant: "high", @@ -432,7 +432,7 @@ describe("run interactive runtime", () => { target: async () => ({ sessionID: "ses-resume-abort", sessionTitle: "Cached title", - location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, agent: "build", model: undefined, variant: undefined, @@ -490,7 +490,7 @@ describe("run interactive runtime", () => { location: { directory: "/session", workspaceID: "work-1", - project: { id: "pro-1", directory: "/session" }, + project: { id: "pro-1", directory: "/session", canonical: "/session" }, }, data: [{ path: "src/index.ts", type: "file" }], } as never) @@ -507,7 +507,7 @@ describe("run interactive runtime", () => { location: { directory: "/session", workspaceID: "work-1", - project: { id: "location-project", directory: "/session" }, + project: { id: "location-project", directory: "/session", canonical: "/session" }, }, agent: "review", model: { providerID: "openai", modelID: "gpt-5" }, diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 2a1f828e28..604384c925 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -167,7 +167,11 @@ function sdk(input: { location: { directory: input.globalLocation?.directory ?? "/tmp", workspaceID: input.globalLocation?.workspaceID, - project: { id: "proj_1", directory: input.globalLocation?.directory ?? "/tmp" }, + project: { + id: "proj_1", + directory: input.globalLocation?.directory ?? "/tmp", + canonical: input.globalLocation?.directory ?? "/tmp", + }, }, data: input.globals ?? [], }), From cb37a7166af2046d5676d86f22ad4f31db1b7b9a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Jul 2026 22:23:10 -0400 Subject: [PATCH 47/51] feat(tui): make session tab switching fast for long transcripts (#39568) --- packages/tui/src/routes/session/index.tsx | 111 +++++++++++++++------- packages/tui/src/routes/session/rows.ts | 19 ++-- 2 files changed, 90 insertions(+), 40 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index eb09929ad3..07dd461a1f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -98,6 +98,13 @@ addDefaultParsers(parsers.parsers) // Exclude temporary bottom space when measuring the real transcript height. const NAVIGATION_SLACK_ID = "session-navigation-slack" +// Tail-first transcript mounting: rows mounted with the session, then backfill cadence. +// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript +// in a few hundred milliseconds without a perceptible pause. +const TRANSCRIPT_TAIL_ROWS = 40 +const TRANSCRIPT_BACKFILL_CHUNK = 60 +const TRANSCRIPT_BACKFILL_DELAY = 120 + const context = createContext<{ width: number sessionID: string @@ -296,6 +303,49 @@ export function Session() { r.set(route.prompt) } + /** Runs after layout has settled (two frames), unless the transcript was torn down. */ + const afterLayout = (continuation: () => void) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!scroll || scroll.isDestroyed) return + continuation() + }) + }) + } + + // Tail-first transcript mounting: only the newest rows mount when the session opens, and the + // rest backfill in chunks shortly after, so switching to a long session costs the visible tail + // instead of the whole transcript. Until backfill pins the count, the hidden span derives from + // the row count, so it needs no effect ordering; the clamp keeps at least a tail visible when a + // re-reduce shrinks the transcript. Streaming appends land at the end of the visible slice. + const [hiddenRows, setHiddenRows] = createSignal() + const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS))) + const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden()))) + createEffect(() => { + const current = hidden() + if (current === 0) return + // Until the first chunk pins hiddenRows, appends change hidden() and reset this timer, so + // backfill waits for a pause in streaming before starting. Once pinned, it drains on a fixed + // cadence undisturbed by appends. + const timer = setTimeout(() => { + const before = scroll && !scroll.isDestroyed ? scroll.scrollHeight : undefined + const viewportBottom = before === undefined ? 0 : scroll.scrollTop + scroll.viewport.height + setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK)) + if (before === undefined) return + // Sticky scroll holds bottom-anchored readers through the mount; compensation is only for + // readers who have scrolled up. + if (viewportBottom >= before - 1) return + afterLayout(() => scroll.scrollBy(scroll.scrollHeight - before)) + }, TRANSCRIPT_BACKFILL_DELAY) + onCleanup(() => clearTimeout(timer)) + }) + /** Message navigation needs the full transcript mounted before walking or jumping. */ + const ensureAllRows = (continuation: () => void) => { + if (hidden() === 0) return continuation() + setHiddenRows(0) + afterLayout(continuation) + } + createEffect(() => { const current = prompt() if (sent || !current || !synced() || !local.model.ready) return @@ -322,41 +372,36 @@ export function Session() { currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0, }), ) - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (scroll.isDestroyed || navigationMessage() !== messageID) return - scroll.scrollTo(top) + afterLayout(() => { + if (navigationMessage() !== messageID) return + scroll.scrollTo(top) + }) + } + + const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType, userOnly = false) => + ensureAllRows(() => { + const target = findMessageBoundary({ + direction, + children: scroll.getChildren(), + messages: messages(), + scrollTop: scroll.scrollTop, + viewportY: scroll.viewport.y, + currentID: navigationMessage(), + userOnly, }) - }) - } - const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType, userOnly = false) => { - const target = findMessageBoundary({ - direction, - children: scroll.getChildren(), - messages: messages(), - scrollTop: scroll.scrollTop, - viewportY: scroll.viewport.y, - currentID: navigationMessage(), - userOnly, - }) - - if (!target) { + if (target) alignMessage(target.id, target.top) dialog.clear() - return - } + }) - alignMessage(target.id, target.top) - dialog.clear() - } - - const jumpToMessage = (messageID: string) => { - const child = scroll.getRenderable(messageID) - if (!child) return - const y = scroll.scrollTop + child.y - scroll.viewport.y - const message = data.session.message.get(route.sessionID, messageID) - alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0))) - } + const jumpToMessage = (messageID: string) => + ensureAllRows(() => { + const child = scroll.getRenderable(messageID) + if (!child) return + const y = scroll.scrollTop + child.y - scroll.viewport.y + const message = data.session.message.get(route.sessionID, messageID) + alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0))) + }) function toBottom() { clearMessageNavigation() @@ -932,12 +977,12 @@ export function Session() { flexGrow={1} scrollAcceleration={scrollAcceleration()} > - + {(row, index) => ( data.session.message.get(route.sessionID, messageID)} - boundaryID={boundaries()[index()]} + boundaryID={boundaries()[index() + hidden()]} /> )} diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 1f8e814793..928a9fe01a 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -97,11 +97,16 @@ export function createSessionRows(sessionID: Accessor) { }), ) - // Re-reduce when the revert boundary changes (stage/clear/commit). + // Re-reduce when the revert boundary changes (stage/clear/commit). These reactions defer + // their first run: the mount effect above has already reduced the same state. createEffect( - on(revertBoundary, () => { - setRows(reconcile(reduce())) - }), + on( + revertBoundary, + () => { + setRows(reconcile(reduce())) + }, + { defer: true }, + ), ) createEffect( @@ -112,6 +117,7 @@ export function createSessionRows(sessionID: Accessor) { .filter((item) => item.type === "compaction") .map((item) => item.id), () => setRows(reconcile(reduce())), + { defer: true }, ), ) @@ -137,12 +143,11 @@ export function createSessionRows(sessionID: Accessor) { : [], ), () => setRows(reconcile(reduce())), + { defer: true }, ), ) - createEffect( - on(turnTokens, () => setRows(reconcile(reduce()))), - ) + createEffect(on(turnTokens, () => setRows(reconcile(reduce())), { defer: true })) const appendMessage = (messageID: string) => setRows( From c161978852faf18455ff31d137aee29da1200099 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Jul 2026 22:23:13 -0400 Subject: [PATCH 48/51] feat(tui): prefetch open session tabs after connect (#39589) --- packages/tui/src/context/session-tabs.tsx | 46 ++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 9daeb4fd54..6df7a2cbd2 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -1,6 +1,7 @@ -import { createEffect, onCleanup } from "solid-js" +import { createEffect, createMemo, onCleanup } from "solid-js" import { isDeepEqual } from "remeda" import { createSimpleContext } from "./helper" +import { useClient } from "./client" import { useData } from "./data" import { useEvent } from "./event" import { useRoute } from "./route" @@ -31,10 +32,14 @@ type PersistedState = { const empty = (): TabsState => ({ tabs: [], unread: {} }) +// Deliberately after connect settles: the visible session's mount syncs win the first slots. +const TAB_PREFETCH_DELAY = 300 + export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({ name: "SessionTabs", init: () => { const route = useRoute() + const client = useClient() const data = useData() const event = useEvent() const config = useConfig().data @@ -129,6 +134,45 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }) }) + // Warm open tabs' session data so first switches render from cache instead of fetching inside + // the switch gesture. Uses only existing sync methods (each dedupes internally), so reruns on + // tab-set or connection changes are no-ops for already-warm sessions, and reconnects double as + // a cache refresh after an SSE gap. The delay lets the current session's own mount syncs get + // the first connection slots. The effect tracks only the id set: reorders, tab switches, and + // title updates neither restart the timer nor an in-flight warm pass; the timer callback + // itself runs untracked, where the current session is skipped. + const openTabSessions = createMemo(() => + state() + .tabs.map((tab) => tab.sessionID) + .sort() + .join("\n"), + ) + createEffect(() => { + if (!enabled()) return + if (client.connection.status() !== "connected") return + if (openTabSessions() === "") return + let stale = false + const timer = setTimeout(async () => { + const sessions = state() + .tabs.map((tab) => tab.sessionID) + .filter((sessionID) => sessionID !== current()) + for (const sessionID of sessions) { + if (stale) return + await Promise.allSettled([ + data.session.sync(sessionID), + data.session.message.sync(sessionID), + data.session.pending.sync(sessionID), + data.session.permission.sync(sessionID), + data.session.form.sync(sessionID), + ]) + } + }, TAB_PREFETCH_DELAY) + onCleanup(() => { + stale = true + clearTimeout(timer) + }) + }) + onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity"))) onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity"))) onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error"))) From 78c139b8b5700904793b75f5bcb02e431e76126b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Jul 2026 22:57:39 -0400 Subject: [PATCH 49/51] feat(plugin): add ui.tabs API for session tab control (#39591) --- packages/plugin/src/tui/context.ts | 19 +++++++++++++++++++ packages/tui/src/plugin/context.tsx | 29 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 374e56aabe..5f775167ff 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -351,6 +351,25 @@ export interface UI { navigate(destination: Destination): void current(): Route } + readonly tabs: { + /** Returns whether session tabs are enabled for this TUI. */ + enabled(): boolean + /** Returns the currently open root-session tabs. Reactive when read in a Solid computation. */ + list(): readonly { + readonly sessionID: string + readonly title?: string + readonly active: boolean + readonly busy: boolean + readonly attention: boolean + readonly unread?: "activity" | "error" + }[] + /** Opens (or focuses) a tab for a session, adding it when not already open. Returns false when tabs are disabled. */ + open(sessionID: string): boolean + /** Focuses an already-open tab and returns false when it is not open. */ + focus(sessionID: string): boolean + /** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */ + close(sessionID?: string): boolean + } readonly slot: (name: Name, render: Slot) => () => void } diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 6b9d019db1..0aa465c6fa 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -33,6 +33,7 @@ import { useDialog } from "../ui/dialog" import { useToast } from "../ui/toast" import { useAttention } from "../context/attention" import { useStorage } from "../context/storage" +import { useSessionTabs } from "../context/session-tabs" import { abbreviateHome } from "../util/path-format" import { builtins } from "./builtins" import { discoverTuiPlugins } from "./discovery" @@ -94,6 +95,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const toast = useToast() const attention = useAttention() const storage = useStorage() + const sessionTabs = useSessionTabs() const directory = config.path ? path.dirname(config.path) : process.cwd() const [store, setStore] = createStore({ ready: false, @@ -278,6 +280,33 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> return route.data }, }, + tabs: { + enabled: sessionTabs.enabled, + list: () => + sessionTabs.tabs().map((tab) => ({ + ...tab, + active: sessionTabs.current() === tab.sessionID, + ...sessionTabs.status(tab.sessionID), + })), + open(sessionID) { + if (!sessionTabs.enabled()) return false + sessionTabs.select(sessionID) + return true + }, + focus(sessionID) { + if (!sessionTabs.enabled()) return false + if (!sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false + sessionTabs.select(sessionID) + return true + }, + close(sessionID) { + if (!sessionTabs.enabled()) return false + const target = sessionID ?? sessionTabs.current() + if (!target || !sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false + sessionTabs.close(target) + return true + }, + }, slot(name, render) { if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`) setStore("registrations", item.plugin.id, "slots", name, () => (input: SlotMap[typeof name]) => ( From 12971935ab36499425f10f202799139d5fb80ea5 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:42:18 -0500 Subject: [PATCH 50/51] feat(core): parse shell permission commands (#39567) --- bun.lock | 15 +- packages/cli/package.json | 3 + packages/cli/script/build-node.ts | 2 +- packages/cli/script/node-assets.ts | 6 +- packages/cli/src/node/target.ts | 5 + packages/cli/vite.node.config.ts | 11 +- packages/core/package.json | 8 + packages/core/src/shell/parse.ts | 296 ++++++++++++++++++++ packages/core/src/shell/parser-wasm.bun.ts | 8 + packages/core/src/shell/parser-wasm.node.ts | 12 + packages/core/src/tool/plugin/shell.ts | 46 +-- packages/core/test/shell-parse.test.ts | 55 ++++ packages/core/test/tool-shell.test.ts | 76 ++++- 13 files changed, 513 insertions(+), 30 deletions(-) create mode 100644 packages/core/src/shell/parse.ts create mode 100644 packages/core/src/shell/parser-wasm.bun.ts create mode 100644 packages/core/src/shell/parser-wasm.node.ts create mode 100644 packages/core/test/shell-parse.test.ts diff --git a/bun.lock b/bun.lock index 0898a51c83..b2fa4cd155 100644 --- a/bun.lock +++ b/bun.lock @@ -402,8 +402,11 @@ "immer": "11.1.4", "jsonc-parser": "3.3.1", "semver": "^7.6.3", + "tree-sitter-bash": "0.25.0", + "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "venice-ai-sdk-provider": "2.1.1", + "web-tree-sitter": "0.25.10", "which": "6.0.1", "zod": "catalog:", }, @@ -1073,9 +1076,11 @@ }, "trustedDependencies": [ "esbuild", + "tree-sitter-powershell", "protobufjs", "electron", "web-tree-sitter", + "tree-sitter-bash", ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", @@ -4072,10 +4077,10 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - "ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -5634,6 +5639,10 @@ "traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="], + "tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="], + + "tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="], + "treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -6856,6 +6865,8 @@ "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="], + "tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index 94a4b20aee..5f0ea9dddf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,6 +40,9 @@ "open": "10.1.2", "semver": "catalog:", "solid-js": "catalog:", + "tree-sitter-bash": "0.25.0", + "tree-sitter-powershell": "0.25.10", + "web-tree-sitter": "0.25.10", "uqr": "0.1.3", "ws": "8.21.0" }, diff --git a/packages/cli/script/build-node.ts b/packages/cli/script/build-node.ts index 9a67fd35d2..8bb9dda9f6 100644 --- a/packages/cli/script/build-node.ts +++ b/packages/cli/script/build-node.ts @@ -62,8 +62,8 @@ for (const target of targets) { await rm("dist-node", { recursive: true, force: true }) const assetHash = await hashNodeAssets(assets) const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target } - await build(mainConfig(input)) await copyNodeAssets(assets) + await build(mainConfig(input)) const host = target.platform === process.platform && target.arch === process.arch if (host) { diff --git a/packages/cli/script/node-assets.ts b/packages/cli/script/node-assets.ts index 3f4823d823..adb871212e 100644 --- a/packages/cli/script/node-assets.ts +++ b/packages/cli/script/node-assets.ts @@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises" import path from "node:path" import { fileURLToPath } from "node:url" import { getNodeAssets } from "@opentui/core/node-assets" -import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target" +import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target" const dir = path.resolve(import.meta.dirname, "..") @@ -43,6 +43,10 @@ export async function collectNodeAssets(target: NodeTarget) { key: photonWasmAsset, source: fileURLToPath(import.meta.resolve(photonWasmAsset)), }, + ...Object.values(shellParserWasmAssets).map((key) => ({ + key, + source: fileURLToPath(import.meta.resolve(key)), + })), ...attentionSoundAssets.map((key) => ({ key, source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)), diff --git a/packages/cli/src/node/target.ts b/packages/cli/src/node/target.ts index f39db8f820..4e62e4a02d 100644 --- a/packages/cli/src/node/target.ts +++ b/packages/cli/src/node/target.ts @@ -29,6 +29,11 @@ export function nodeTarget(platform: string, arch: string) { } export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm" +export const shellParserWasmAssets = { + runtime: "web-tree-sitter/tree-sitter.wasm", + bash: "tree-sitter-bash/tree-sitter-bash.wasm", + powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm", +} as const export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const export const attentionSoundAssets = [ diff --git a/packages/cli/vite.node.config.ts b/packages/cli/vite.node.config.ts index 7d7caf6d05..8b7bf9c826 100644 --- a/packages/cli/vite.node.config.ts +++ b/packages/cli/vite.node.config.ts @@ -3,7 +3,13 @@ import { readFile } from "node:fs/promises" import { createRequire } from "node:module" import { defineConfig, type Plugin, type UserConfig } from "vite" import solid from "vite-plugin-solid" -import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target" +import { + nodeExecArgv, + nodeTarget, + type NodeTarget, + photonWasmAsset, + shellParserWasmAssets, +} from "./src/node/target" const dir = import.meta.dirname @@ -194,6 +200,9 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)}) process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)}) process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)}) +process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)}) +process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)}) +process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)}) process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)}) process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)}) try { diff --git a/packages/core/package.json b/packages/core/package.json index 4d6f46c1c2..3b208f6f25 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,6 +41,11 @@ "node": "./src/image/photon-wasm.node.ts", "default": "./src/image/photon-wasm.bun.ts" }, + "#shell-parser-wasm": { + "bun": "./src/shell/parser-wasm.bun.ts", + "node": "./src/shell/parser-wasm.node.ts", + "default": "./src/shell/parser-wasm.bun.ts" + }, "#process-lock-ffi": { "bun": "./src/util/process-lock-ffi.bun.ts", "node": "./src/util/process-lock-ffi.node.ts", @@ -119,7 +124,10 @@ "jsonc-parser": "3.3.1", "semver": "^7.6.3", "turndown": "7.2.0", + "tree-sitter-bash": "0.25.0", + "tree-sitter-powershell": "0.25.10", "venice-ai-sdk-provider": "2.1.1", + "web-tree-sitter": "0.25.10", "which": "6.0.1", "zod": "catalog:" }, diff --git a/packages/core/src/shell/parse.ts b/packages/core/src/shell/parse.ts new file mode 100644 index 0000000000..ec09e7e718 --- /dev/null +++ b/packages/core/src/shell/parse.ts @@ -0,0 +1,296 @@ +export * as ShellParse from "./parse" + +import { Effect } from "effect" +import { fileURLToPath } from "url" +import os from "os" +import path from "path" +import type { Node } from "web-tree-sitter" +import { shellParserWasm } from "#shell-parser-wasm" +import { ShellSelect } from "./select" + +type Part = { type: string; text: string } +const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) +const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"]) + +const ARITY: Record = { + cat: 1, + cd: 1, + chmod: 1, + chown: 1, + cp: 1, + echo: 1, + env: 1, + export: 1, + grep: 1, + kill: 1, + killall: 1, + ln: 1, + ls: 1, + mkdir: 1, + mv: 1, + ps: 1, + pwd: 1, + rm: 1, + rmdir: 1, + sleep: 1, + source: 1, + tail: 1, + touch: 1, + unset: 1, + which: 1, + aws: 3, + az: 3, + bazel: 2, + brew: 2, + bun: 2, + "bun run": 3, + "bun x": 3, + cargo: 2, + "cargo add": 3, + "cargo run": 3, + cdk: 2, + cf: 2, + cmake: 2, + composer: 2, + consul: 2, + "consul kv": 3, + crictl: 2, + deno: 2, + "deno task": 3, + doctl: 3, + docker: 2, + "docker builder": 3, + "docker compose": 3, + "docker container": 3, + "docker image": 3, + "docker network": 3, + "docker volume": 3, + eksctl: 2, + "eksctl create": 3, + firebase: 2, + flyctl: 2, + gcloud: 3, + gh: 3, + git: 2, + "git config": 3, + "git remote": 3, + "git stash": 3, + go: 2, + gradle: 2, + helm: 2, + heroku: 2, + hugo: 2, + ip: 2, + "ip addr": 3, + "ip link": 3, + "ip netns": 3, + "ip route": 3, + kind: 2, + "kind create": 3, + kubectl: 2, + "kubectl kustomize": 3, + "kubectl rollout": 3, + kustomize: 2, + make: 2, + mc: 2, + "mc admin": 3, + minikube: 2, + mongosh: 2, + mysql: 2, + mvn: 2, + ng: 2, + npm: 2, + "npm exec": 3, + "npm init": 3, + "npm run": 3, + "npm view": 3, + npx: 2, + nvm: 2, + nx: 2, + openssl: 2, + "openssl req": 3, + "openssl x509": 3, + pip: 2, + pipenv: 2, + pnpm: 2, + "pnpm dlx": 3, + "pnpm exec": 3, + "pnpm run": 3, + poetry: 2, + podman: 2, + "podman container": 3, + "podman image": 3, + psql: 2, + pulumi: 2, + "pulumi stack": 3, + python: 2, + pyenv: 2, + rake: 2, + rbenv: 2, + "redis-cli": 2, + rustup: 2, + serverless: 2, + sfdx: 3, + skaffold: 2, + sls: 2, + sst: 2, + swift: 2, + systemctl: 2, + terraform: 2, + "terraform workspace": 3, + tmux: 2, + turbo: 2, + ufw: 2, + vault: 2, + "vault auth": 3, + "vault kv": 3, + vercel: 2, + volta: 2, + wp: 2, + yarn: 2, + "yarn dlx": 3, + "yarn run": 3, +} + +export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) { + const parsers = yield* Effect.promise(load) + const powershell = ShellSelect.ps(shell) + const tree = (powershell ? parsers.ps : parsers.bash).parse(command) + if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command")) + + return yield* Effect.acquireUseRelease( + Effect.succeed(tree), + (tree) => + Effect.sync(() => + tree.rootNode.descendantsOfType("command").reduce( + (result, node) => { + if (!node) return result + const command = parts(node) + const tokens = command.map((part) => part.text) + if (tokens.length === 0) return result + const name = powershell ? tokens[0].toLowerCase() : tokens[0] + if (CWD.has(name)) { + result.directories.push(...directoryArgs(command, powershell, cwd, shell)) + return result + } + result.commands.push({ + resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(), + save: `${prefix(tokens).join(" ")} *`, + }) + return result + }, + { commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] }, + ), + ), + (tree) => Effect.sync(() => tree.delete()), + ) +}) + +function parts(node: Node) { + return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => { + const child = node.child(index) + if (!child) return [] + if (child.type === "command_elements") + return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => { + const item = child.child(itemIndex) + if (!item || item.type === "command_argument_sep" || item.type === "redirection") return [] + return [{ type: item.type, text: item.text }] + }) + if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type)) + return [] + return [{ type: child.type, text: child.text }] + }) +} + +function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) { + if (!powershell) + return command + .slice(1) + .filter((part) => !part.text.startsWith("-")) + .map((part) => directoryArgument(part.text, powershell, cwd, shell)) + .filter((part) => part !== undefined) + + const directories: string[] = [] + let path = false + for (const part of command.slice(1)) { + if (path) { + const value = directoryArgument(part.text, powershell, cwd, shell) + if (value) directories.push(value) + path = false + continue + } + if (part.type === "command_parameter") { + path = POWERSHELL_PATH_FLAGS.has(part.text.toLowerCase()) + continue + } + const value = directoryArgument(part.text, powershell, cwd, shell) + if (value) directories.push(value) + } + return directories +} + +function directoryArgument(value: string, powershell: boolean, cwd: string, shell: string) { + const quote = value[0] + const text = (quote === '"' || quote === "'") && value.at(-1) === quote ? value.slice(1, -1) : value + if (!powershell) return expandKnownDirectory(text) + + // PowerShell exposes environment variables through $env:NAME and provides these + // automatic directory variables. Expand only values we can determine without executing code. + return expandKnownDirectory( + text + .replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => environment(key) ?? "") + .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => environment(key) ?? "") + .replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => { + if (key.toUpperCase() === "HOME") return os.homedir() + if (key.toUpperCase() === "PWD") return cwd + return path.dirname(shell) + }), + ) +} + +function expandKnownDirectory(value: string) { + // Unknown shell expressions cannot be resolved safely during permission analysis. + if (value.includes("$") || value.includes("`") || value.startsWith("(")) return + if (value === "~") return os.homedir() + if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2)) + return value +} + +function environment(key: string) { + if (process.platform !== "win32") return process.env[key] + const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase()) + return name ? process.env[name] : undefined +} + +function prefix(tokens: string[]) { + for (let length = tokens.length; length > 0; length--) { + const arity = ARITY[tokens.slice(0, length).join(" ")] + if (arity !== undefined) return tokens.slice(0, arity) + } + return tokens.slice(0, 1) +} + +function resolve(asset: string) { + if (asset.startsWith("file://")) return fileURLToPath(asset) + if (path.isAbsolute(asset)) return asset + return fileURLToPath(new URL(asset, import.meta.url)) +} + +const load = (() => { + let loading: ReturnType | undefined + return () => (loading ??= initialize()) +})() + +async function initialize() { + const { Parser, Language } = await import("web-tree-sitter") + await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) }) + const [bashLanguage, psLanguage] = await Promise.all([ + Language.load(resolve(shellParserWasm.bash)), + Language.load(resolve(shellParserWasm.powershell)), + ]) + const bash = new Parser() + bash.setLanguage(bashLanguage) + const ps = new Parser() + ps.setLanguage(psLanguage) + return { bash, ps } +} diff --git a/packages/core/src/shell/parser-wasm.bun.ts b/packages/core/src/shell/parser-wasm.bun.ts new file mode 100644 index 0000000000..278a169d9b --- /dev/null +++ b/packages/core/src/shell/parser-wasm.bun.ts @@ -0,0 +1,8 @@ +// @ts-ignore Bun embeds static file imports when compiling the CLI. +import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" } +// @ts-ignore Bun embeds static file imports when compiling the CLI. +import bash from "tree-sitter-bash/tree-sitter-bash.wasm" with { type: "file" } +// @ts-ignore Bun embeds static file imports when compiling the CLI. +import powershell from "tree-sitter-powershell/tree-sitter-powershell.wasm" with { type: "file" } + +export const shellParserWasm = { runtime, bash, powershell } diff --git a/packages/core/src/shell/parser-wasm.node.ts b/packages/core/src/shell/parser-wasm.node.ts new file mode 100644 index 0000000000..9b2527c9cc --- /dev/null +++ b/packages/core/src/shell/parser-wasm.node.ts @@ -0,0 +1,12 @@ +import { createRequire } from "node:module" + +const require = createRequire(import.meta.url) + +export const shellParserWasm = { + runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"), + bash: + process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"), + powershell: + process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ?? + require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"), +} diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 89ad765a83..ae6413df1e 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -1,5 +1,6 @@ export * as ShellTool from "./shell" +import path from "path" import { ToolFailure } from "@opencode-ai/ai" import type { Content } from "@opencode-ai/schema/tool" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" @@ -11,6 +12,7 @@ import { PluginRuntime } from "../../plugin/runtime" import { NonNegativeInt } from "../../schema" import { SessionSchema } from "../../session/schema" import { Shell } from "../../shell" +import { ShellParse } from "../../shell/parse" export const name = "shell" export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 @@ -75,18 +77,6 @@ const modelOutput = (output: Output): string | undefined => { return `Command exited with code ${output.exit}.` } -/** - * Minimal core shell boundary. Keep parity debt visible without pulling the - * legacy shell runtime into core. - */ -// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction. -// TODO: Port BashArity reusable command-prefix approvals. -// TODO: Add plugin shell.env environment augmentation once plugin hooks exist. -// TODO: Persist job status and define restart recovery before exposing remote observation. -// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined. -// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it. -// TODO: Revisit binary output handling if stdout/stderr decoding is text-only. - export const Plugin = { id: "opencode.tool.shell", effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) { @@ -158,24 +148,34 @@ export const Plugin = { (invocation) => Effect.gen(function* () { const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" }) + const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical) + const directories = yield* Effect.forEach(parsed.directories, (directory) => + mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }), + ) invocation.cwd = target.canonical finalTimeout = invocation.timeout - const external = target.externalDirectory - if (external) + const external = [target, ...directories] + .map((item) => item.externalDirectory) + .filter((item) => item !== undefined) + .filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index) + if (external.length > 0) yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), + action: "external_directory", + resources: external.map((item) => item.resource), + save: external.map((item) => item.save), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + if (parsed.commands.length > 0) + yield* permission.assert({ + action: name, + resources: parsed.commands.map((command) => command.resource), + save: parsed.commands.map((command) => command.save), sessionID: context.sessionID, agent: context.agent, source, }) - yield* permission.assert({ - action: name, - resources: [invocation.command], - save: [invocation.command], - sessionID: context.sessionID, - agent: context.agent, - source, - }) if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) }), diff --git a/packages/core/test/shell-parse.test.ts b/packages/core/test/shell-parse.test.ts new file mode 100644 index 0000000000..8b7525a854 --- /dev/null +++ b/packages/core/test/shell-parse.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import os from "os" +import path from "path" +import { ShellParse } from "@opencode-ai/core/shell/parse" + +describe("ShellParse", () => { + test("splits bash commands and derives reusable prefixes", async () => { + const result = await Effect.runPromise( + ShellParse.scan("git status && npm run test -- --watch", "/bin/bash", "/workspace"), + ) + expect(result).toEqual({ + commands: [ + { resource: "git status", save: "git status *" }, + { resource: "npm run test -- --watch", save: "npm run test *" }, + ], + directories: [], + }) + }) + + test("splits PowerShell commands case-insensitively", async () => { + const result = await Effect.runPromise( + ShellParse.scan("Get-ChildItem; Write-Output 'done'", "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "C:\\workspace"), + ) + expect(result.commands).toEqual([ + { resource: "Get-ChildItem", save: "Get-ChildItem *" }, + { resource: "Write-Output 'done'", save: "Write-Output *" }, + ]) + }) + + test("does not permission directory changes separately", async () => { + const result = await Effect.runPromise(ShellParse.scan("cd 'src dir' && git status", "/bin/bash", "/workspace")) + expect(result).toEqual({ + commands: [{ resource: "git status", save: "git status *" }], + directories: ["src dir"], + }) + }) + + test("extracts PowerShell directory parameters", async () => { + const result = await Effect.runPromise( + ShellParse.scan("Set-Location -LiteralPath '..\\outside'; Get-ChildItem", "pwsh", "C:\\workspace"), + ) + expect(result.directories).toEqual(["..\\outside"]) + }) + + test("expands deterministic directory variables", async () => { + const bash = await Effect.runPromise(ShellParse.scan("cd ~/src", "/bin/bash", "/workspace")) + expect(bash.directories).toEqual([path.join(os.homedir(), "src")]) + + const powershell = await Effect.runPromise( + ShellParse.scan('Set-Location "$PWD/src"; Set-Location $PSHOME', "/usr/local/bin/pwsh", "/workspace"), + ) + expect(powershell.directories).toEqual(["/workspace/src", "/usr/local/bin"]) + }) +}) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 33f81161ce..1e313e723e 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -1,5 +1,6 @@ import fs from "fs/promises" import { realpathSync } from "node:fs" +import os from "os" import path from "path" import { describe, expect } from "bun:test" import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect" @@ -222,7 +223,10 @@ describe("ShellTool", () => { type: "text", text: expect.stringContaining("Command exited with code 0."), }) - expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: [helloCommand] }]) + expect(assertions).toMatchObject([ + { sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] }, + ]) + expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"]) }), ) }, @@ -253,6 +257,29 @@ describe("ShellTool", () => { ), ) + it.live("permissions compound commands separately", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + return withSession(tmp.path, (registry) => + executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")), + ).pipe( + Effect.andThen( + Effect.sync(() => { + expect(assertions).toHaveLength(1) + expect(assertions[0]).toMatchObject({ + resources: ["printf one", "printf two"], + save: ["printf *", "printf *"], + }) + }), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + ) + it.live("captures stderr-only and mixed stdout/stderr output", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -325,6 +352,51 @@ describe("ShellTool", () => { ), ) + it.live("approves an external directory used by a directory-change command", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { + reset() + const command = isWindows + ? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path` + : `cd '${outside.path}' && pwd` + return withSession(active.path, (registry) => executeTool(registry, call({ command }, "call-external-cd"))).pipe( + Effect.andThen( + Effect.sync(() => { + expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"]) + expect(assertions[0]).toMatchObject({ + resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")], + }) + }), + ), + ) + }, + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + + it.live("approves an expanded external home directory", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const command = isWindows ? "Set-Location $HOME; (Get-Location).Path" : "cd ~ && pwd" + return withSession(tmp.path, (registry) => executeTool(registry, call({ command }, "call-external-home"))).pipe( + Effect.andThen( + Effect.sync(() => { + expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"]) + expect(assertions[0]?.resources[0]).toStartWith(os.homedir().replaceAll("\\", "/")) + }), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + ) + it.live("does not execute after external-directory or shell denial", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), @@ -466,7 +538,7 @@ describe("ShellTool", () => { (tmp) => { reset() return withSession(tmp.path, (registry) => - executeTool(registry, call({ command: timeoutOutputCommand, timeout: 50 })), + executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 500 : 50 })), ).pipe( Effect.andThen((settled) => Effect.sync(() => { From 906dc8f5b2d101b707535ea5621fe9b841659ad8 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:42:47 -0500 Subject: [PATCH 51/51] fix(ai): apply catalog settings to provider models (#39613) --- packages/ai/src/providers/openrouter.ts | 20 ++++++++++++++- packages/ai/src/providers/xai.ts | 17 ++++++++++++- packages/ai/test/exports.test.ts | 2 -- packages/ai/test/provider-package.test.ts | 31 +++++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts index 4bcdb3f9db..29f75b409e 100644 --- a/packages/ai/src/providers/openrouter.ts +++ b/packages/ai/src/providers/openrouter.ts @@ -5,6 +5,7 @@ import { Framing } from "../route/framing" import { Protocol } from "../route/protocol" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import type { ProviderPackage } from "../provider-package" import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAIChat from "../protocols/openai-chat" import { isRecord } from "../protocols/shared" @@ -30,6 +31,12 @@ export type ModelOptions = Omit & readonly providerOptions?: OpenRouterProviderOptionsInput } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly providerOptions?: OpenRouterProviderOptionsInput +} + const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [ Schema.Record(Schema.String, Schema.Any), ]) @@ -113,4 +120,15 @@ export const configure = (input: ModelOptions = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + }).model(modelID) diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts index 37aae83a52..281a106357 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -6,6 +6,7 @@ import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import * as OpenAIResponses from "../protocols/openai-responses" import { XAIImages } from "../protocols/xai-images" import type { OpenAIProviderOptionsInput } from "./openai-options" +import type { ProviderPackage } from "../provider-package" export const id = ProviderID.make("xai") @@ -15,6 +16,12 @@ export type ModelOptions = Omit & readonly providerOptions?: OpenAIProviderOptionsInput } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly providerOptions?: OpenAIProviderOptionsInput +} + export type { XAIImageOptions } from "../protocols/xai-images" export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route] @@ -65,7 +72,15 @@ export const configure = (input: ModelOptions = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + }).model(modelID) export const responses = provider.responses export const chat = provider.chat export const image = provider.image diff --git a/packages/ai/test/exports.test.ts b/packages/ai/test/exports.test.ts index cb32528634..d036392278 100644 --- a/packages/ai/test/exports.test.ts +++ b/packages/ai/test/exports.test.ts @@ -53,9 +53,7 @@ describe("public exports", () => { expect(CloudflareWorkersAI.configure).toBeFunction() expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction() expect(OpenRouter.model).toBeFunction() - expect(OpenRouter.provider.model).toBe(OpenRouter.model) expect(XAI.model).toBeFunction() - expect(XAI.provider.model).toBe(XAI.model) expect(XAI.provider.responses).toBe(XAI.responses) expect(XAI.provider.chat).toBe(XAI.chat) expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses") diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts index 45ab7dfa84..0bb1b1441b 100644 --- a/packages/ai/test/provider-package.test.ts +++ b/packages/ai/test/provider-package.test.ts @@ -21,6 +21,8 @@ describe("provider package entrypoints", () => { import("@opencode-ai/ai/providers/google-vertex/chat"), import("@opencode-ai/ai/providers/google-vertex/responses"), import("@opencode-ai/ai/providers/google-vertex/messages"), + import("@opencode-ai/ai/providers/openrouter"), + import("@opencode-ai/ai/providers/xai"), ]) for (const module of modules) expect(module.model).toBeFunction() @@ -29,6 +31,35 @@ describe("provider package entrypoints", () => { expect(modules[12].model).toBe(modules[13].model) }) + test("maps OpenRouter and xAI package settings onto executable models", async () => { + const OpenRouter = await import("@opencode-ai/ai/providers/openrouter") + const XAI = await import("@opencode-ai/ai/providers/xai") + const settings = { + apiKey: "fixture", + baseURL: "https://provider.example.test/v1", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + } + const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", { + ...settings, + providerOptions: { openrouter: { usage: true } }, + }) + const xai = XAI.model("grok-4", { + ...settings, + providerOptions: { openai: { reasoningEffort: "high" } }, + }) + + for (const selected of [openrouter, xai]) { + expect(selected.route.endpoint.baseURL).toBe(settings.baseURL) + expect(selected.route.defaults.headers).toEqual(settings.headers) + expect(selected.route.defaults.http?.body).toEqual(settings.body) + expect(selected.route.defaults.limits).toEqual(settings.limits) + } + expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } }) + expect(xai.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high", store: false } }) + }) + test("maps package settings onto the executable model", () => { const selected = model("gpt-5", { apiKey: "fixture",