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/bun.lock b/bun.lock index e09ea82a5c..4288f95e82 100644 --- a/bun.lock +++ b/bun.lock @@ -401,8 +401,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:", }, @@ -599,6 +602,7 @@ "zod": "catalog:", }, "devDependencies": { + "@opencode-ai/theme": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", @@ -610,12 +614,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", @@ -1069,9 +1075,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", @@ -5630,6 +5638,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=="], @@ -6850,6 +6862,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/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. 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts index 10b7fdd1e9..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), ]) @@ -107,10 +114,21 @@ 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, } } 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 c5d3220ee7..281a106357 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -5,14 +5,23 @@ 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" +import type { ProviderPackage } from "../provider-package" export const id = ProviderID.make("xai") -export type ModelOptions = RouteDefaultsInput & +export type ModelOptions = Omit & ProviderAuthOption<"optional"> & { readonly baseURL?: string + 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] @@ -42,8 +51,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, @@ -63,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/src/route/client.ts b/packages/ai/src/route/client.ts index 067292329b..b935699f90 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, @@ -46,7 +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, @@ -93,12 +92,12 @@ export interface RoutePatch extends RouteDefaultsInput { 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, @@ -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 } @@ -296,7 +284,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, @@ -370,9 +359,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 +376,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 +408,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 +436,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 +445,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/schema/options.ts b/packages/ai/src/schema/options.ts index c80bce0fda..df7c6544aa 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -178,7 +178,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 +194,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 +204,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 +214,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, }) @@ -241,6 +242,8 @@ export namespace Model { 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" }) export class CacheHint extends Schema.Class("LLM.CacheHint")({ 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/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/llm-option-types.types.ts b/packages/ai/test/llm-option-types.types.ts new file mode 100644 index 0000000000..8171a7b2e9 --- /dev/null +++ b/packages/ai/test/llm-option-types.types.ts @@ -0,0 +1,47 @@ +import { Schema } from "effect" +import { LLM, type 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 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 } }, +}) 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" } } }, +}) 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" } }, +}) 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 } }, +}) 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" } }, +}) 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" } }, +}) 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 } }, +}) 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" } }, +}) 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" } } }, +}) 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" } } }, +}) 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" } }, +}) 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" } }, +}) 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 } }, +}) 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" } }, +}) 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 } }, +}) 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", 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/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 016c678372..6fe0c44560 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/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 3d500d7507..d562c1cb0b 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -131,8 +131,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 983b4043be..fae7431bd7 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -155,6 +155,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/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/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/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/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/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" } }, }) }) 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/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/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/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 fcd25cc09b..8c8b6295a6 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 = { @@ -43,13 +45,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 @@ -283,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 } @@ -628,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 = { @@ -1210,7 +1206,6 @@ export type PromptFileAttachment = { export type PromptAgentAttachment = { name: string; mention?: PromptMention } export type SessionPendingSynthetic = { - admittedSeq: number id: string sessionID: string timeCreated: number @@ -1435,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 @@ -1755,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 @@ -1966,7 +1961,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 @@ -2521,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?: { @@ -2530,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 } @@ -2542,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 } @@ -2553,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 } @@ -2702,7 +2700,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"] @@ -3235,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 } @@ -3246,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 } @@ -3273,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 } @@ -3285,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 } @@ -3296,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 } @@ -3308,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 } @@ -3355,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 @@ -3374,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 } @@ -3409,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 } @@ -3422,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 } @@ -3443,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 } @@ -3532,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 } @@ -3581,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 } @@ -4437,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 } @@ -4559,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 } @@ -4591,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 } @@ -4602,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 } @@ -4613,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 } @@ -4626,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 } @@ -4672,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 } @@ -4684,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 } @@ -4701,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 } @@ -4721,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 } @@ -4756,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 } @@ -4768,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 } @@ -4781,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 } @@ -4805,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 } } @@ -4825,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 } @@ -4855,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 } @@ -4898,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 } @@ -4921,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 } @@ -4942,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 } @@ -4955,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/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/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/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/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/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..b0a13ac58f 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)`.', @@ -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/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/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/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/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/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/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/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/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/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/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 39bb868678..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" @@ -253,7 +254,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 }), @@ -317,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/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 c2cdbb3ffc..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" @@ -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: { @@ -322,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( @@ -344,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, @@ -395,25 +409,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) }), @@ -440,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( @@ -721,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/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/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/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/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/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/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/edit.ts b/packages/core/src/tool/plugin/edit.ts index 57aacecb60..4fe50ed30a 100644 --- a/packages/core/src/tool/plugin/edit.ts +++ b/packages/core/src/tool/plugin/edit.ts @@ -9,12 +9,14 @@ 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 { createTwoFilesPatch, diffLines } from "diff" +import { Bom } from "@opencode-ai/util/bom" 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" @@ -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 @@ -151,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}` })), @@ -167,9 +161,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) @@ -183,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.`, @@ -193,35 +206,17 @@ 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 counts = diffLines(source, replaced).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}`, + 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 return { - files: [ - { - file: result.resource, - patch: createTwoFilesPatch(result.resource, result.resource, source, replaced), - status: "modified" as const, - ...counts, - }, - ], + files: [fileDiff(result.resource, source, formatted)], replacements, } satisfies Output }).pipe( 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/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/shell.ts b/packages/core/src/tool/plugin/shell.ts index 384f60e461..ae6413df1e 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -12,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 @@ -66,51 +67,16 @@ 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}.` } -/** - * 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: 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) { @@ -170,38 +136,50 @@ 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, - }) - 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], - 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" }) + 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, ...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({ + 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, + }) + 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* () { @@ -220,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 ${timeout} 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, @@ -251,20 +229,19 @@ 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, truncated: false, status: "running" as const, - ...(warnings.length ? { warnings } : {}), } } @@ -273,20 +250,19 @@ 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, 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/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.", diff --git a/packages/core/src/tool/plugin/write.ts b/packages/core/src/tool/plugin/write.ts index d5e9411a52..332ade279e 100644 --- a/packages/core/src/tool/plugin/write.ts +++ b/packages/core/src/tool/plugin/write.ts @@ -9,17 +9,20 @@ 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" +import { fileDiff } from "./file-diff" 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" }), }) @@ -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 @@ -55,7 +59,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) => @@ -74,15 +78,29 @@ 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 preview = fileDiff( + target.resource, + current?.text ?? "", + next.text, + current ? "modified" : "added", + ) yield* permission.assert({ action: "edit", resources: [target.resource], save: ["*"], + metadata: { files: [preview] }, sessionID: context.sessionID, 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/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/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 e12df20085..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") @@ -67,6 +92,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 +102,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/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/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")), 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/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/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/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/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-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..975845f450 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"), @@ -118,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), }) @@ -160,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), })), @@ -354,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/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/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 b8f46d91af..61bb24a622 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -34,14 +34,13 @@ 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, }), ) 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-create.test.ts b/packages/core/test/session-create.test.ts index ae048e228b..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 @@ -199,7 +219,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 +272,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 +317,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 } }) @@ -484,7 +526,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/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index ea12b528f2..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" @@ -49,7 +48,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(() => { @@ -192,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-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 3fc569c2f9..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( @@ -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/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-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( 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/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/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-edit.test.ts b/packages/core/test/tool-edit.test.ts index 6e4f11a1d9..35269e37bf 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], ], ), @@ -171,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))]) }), ), @@ -181,6 +200,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()), @@ -302,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") }), @@ -313,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) => { @@ -339,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([]) }), ), @@ -574,6 +626,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-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)`.', 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-shell.test.ts b/packages/core/test/tool-shell.test.ts index df53ae9403..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" @@ -157,6 +158,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" @@ -219,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 *"]) }), ) }, @@ -250,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()), @@ -322,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()])), @@ -346,33 +421,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()), @@ -488,14 +536,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: isWindows ? 500 : 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"), }) diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index cd2781189f..6acb9ca03d 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], ], ), @@ -132,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")]) }), ) @@ -140,6 +159,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()), @@ -155,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", ) @@ -174,6 +228,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/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/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 +} diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 9d092f7245..5f775167ff 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, @@ -19,8 +20,19 @@ 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" + +export interface Storage { + store( + key: string, + options: { + readonly initial: Value + }, + ): readonly [Store, (mutation: (draft: Value) => void) => Promise] +} interface LocationCollection { list(location?: LocationRef): Value[] | undefined @@ -66,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 @@ -116,6 +132,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 } @@ -331,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 } @@ -342,7 +381,8 @@ 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/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/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/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-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/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/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) 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/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/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/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/package.json b/packages/tui/package.json index ac929f9d6e..5fa63c5a76 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", @@ -71,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/app.tsx b/packages/tui/src/app.tsx index 19dc5cedec..c4221ee5b7 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,115 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { worktree: global.data + "/worktree", }} > - finalizers.delete(finalizer) - }, - }} - > - + finalizers.delete(finalizer) + }, }} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -583,7 +595,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 +608,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/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-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/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 5a32abe286..6511a14036 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])) @@ -102,12 +102,12 @@ export function DialogIntegration( title="Connect a service" options={options()} emptyView={ - + No integrations available } noMatchView={ - + No integrations found } @@ -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..af9d8209f5 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() @@ -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-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..ebe93e34fd 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -1,13 +1,14 @@ -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" 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 +23,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() @@ -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/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/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 44e98f92d5..2d554a49b1 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() @@ -234,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) => @@ -301,42 +298,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 +1564,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 - - - + 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, } } 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 ( 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,96 @@ 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) + // 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 + 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 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()) } + // 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 = () => { - 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 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) + // 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("")} - - {(character, index) => ( - - {character} - - )} + + + {(character, index) => {character}} - - + + { event.stopPropagation() tabs.close(tab.sessionID) @@ -241,8 +333,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati }} 0}> - - {layout().after}› + + {" " + layout().after}› diff --git a/packages/tui/src/component/startup-loading.tsx b/packages/tui/src/component/startup-loading.tsx index d340de17c1..04804b3ad4 100644 --- a/packages/tui/src/component/startup-loading.tsx +++ b/packages/tui/src/component/startup-loading.tsx @@ -1,9 +1,9 @@ import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { Spinner } from "./spinner" export function StartupLoading(props: { ready: () => 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/component/tab-pulse.tsx b/packages/tui/src/component/tab-pulse.tsx index b06a4f189d..de2bce5692 100644 --- a/packages/tui/src/component/tab-pulse.tsx +++ b/packages/tui/src/component/tab-pulse.tsx @@ -6,10 +6,14 @@ type TabPulseOptions = RenderableOptions & { active?: boolean complete?: boolean glow?: boolean + 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)) @@ -18,8 +22,18 @@ 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 = 800 +const EDGE_FLASH_ATTACK = 0.1 +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 +47,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)) @@ -46,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 @@ -57,24 +78,75 @@ 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 } + +/** 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 _flashColor: 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, (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 @@ -84,21 +156,49 @@ 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._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() { + 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 +209,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 +226,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() } @@ -154,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 @@ -168,61 +293,63 @@ 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) { + 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++) { - 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._flashColor, this._completionColor, - glow, - running, + glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel, + sweep, + flash, completion, ) buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor) @@ -243,10 +370,13 @@ export function TabPulse(props: { active: boolean complete?: boolean glow?: boolean + breathe?: boolean color: RGBA glowColor?: RGBA + flashColor?: RGBA completionColor?: RGBA backgroundColor: RGBA + onLevel?: (level: number) => void }) { return ( ) } diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 867576d352..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 { @@ -127,6 +129,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( @@ -235,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} ) 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/src/context/data.tsx b/packages/tui/src/context/data.tsx index 01a60f57f1..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: {}, @@ -407,7 +410,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 +704,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", }) @@ -956,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) { @@ -1039,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] @@ -1320,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/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, 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/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 14fc91a508..6df7a2cbd2 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -1,18 +1,17 @@ -import { batch, createEffect, onCleanup, untrack } from "solid-js" -import { createStore, produce, reconcile } from "solid-js/store" -import path from "path" +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" -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, + moveSessionTab, moveSessionTabHistory, openSessionTab, recordSessionTabHistory, @@ -21,33 +20,55 @@ 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: {} }) + +// 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 - 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: {}, + // 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 } + function state() { + 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 ?? "cwd" + 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 +76,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 +84,93 @@ 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 + }, {}) + }) + }) + + // 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) }) - save() }) onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity"))) @@ -200,26 +189,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,21 +219,29 @@ 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 } 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(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 +249,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..a3e1d4f2b8 --- /dev/null +++ b/packages/tui/src/context/storage.tsx @@ -0,0 +1,97 @@ +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 + /** 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] + +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 merge = (next: Value) => reconcile(next, { key: options.key }) + const reload = () => batch(() => setStore(merge(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(merge(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(useTuiPaths().state, 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/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/prompt/footer.tsx b/packages/tui/src/feature-plugins/prompt/footer.tsx new file mode 100644 index 0000000000..341e8494ea --- /dev/null +++ b/packages/tui/src/feature-plugins/prompt/footer.tsx @@ -0,0 +1,89 @@ +import { Plugin } from "@opencode-ai/plugin/tui" +import { createMemo, Match, Show, Switch } from "solid-js" +import { contextUsage, formatContextUsage } from "../../util/session" + +const money = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}) + +export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) { + const activeSubagents = createMemo(() => { + 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/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 deleted file mode 100644 index 9305845c49..0000000000 --- a/packages/tui/src/feature-plugins/system/scrap.tsx +++ /dev/null @@ -1,182 +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" - -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 = props.context.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 - }, - 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/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 Promise } @@ -90,12 +90,12 @@ 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() + const storage = useStorage() + const sessionTabs = useSessionTabs() const directory = config.path ? path.dirname(config.path) : process.cwd() const [store, setStore] = createStore({ ready: false, @@ -224,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, @@ -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, @@ -275,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]) => ( @@ -525,24 +557,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/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. 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/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/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..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 @@ -256,7 +263,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), ]) @@ -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()]} /> )} @@ -1494,7 +1539,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 +1736,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 +1809,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 +1837,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 +1914,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/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( 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..6764b07454 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)) @@ -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 )} @@ -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/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, - ]) - }) -}) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index b94dae7e80..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() } @@ -1346,14 +1396,12 @@ test("restores queued compaction from durable pending input", async () => { 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, @@ -1400,6 +1448,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, @@ -1419,7 +1490,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", @@ -1434,7 +1505,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"]) @@ -2474,7 +2545,6 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn { id: messageID, sessionID, - admittedSeq: 0, timeCreated: 0, type: "user", data: { text: "hello" }, @@ -2599,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 @@ -2627,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/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/component/tab-pulse.test.ts b/packages/tui/test/component/tab-pulse.test.ts index 21561b6e29..0e157f6ba3 100644 --- a/packages/tui/test/component/tab-pulse.test.ts +++ b/packages/tui/test/component/tab-pulse.test.ts @@ -1,16 +1,28 @@ 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", () => { 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) }) +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)) @@ -32,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, + ) + } } } } 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" }]) 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() + } +}) 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 messageID: "m-queued", prompt: { text: "follow up", parts: [] }, delivery: "queue", - admittedSeq: 1, }, ]} theme={() => RUN_THEME_FALLBACK} diff --git a/packages/tui/test/mini/runtime.test.ts b/packages/tui/test/mini/runtime.test.ts index e55e150bc0..3b824cecd1 100644 --- a/packages/tui/test/mini/runtime.test.ts +++ b/packages/tui/test/mini/runtime.test.ts @@ -78,7 +78,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: 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 f3c25ea52c..604384c925 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, @@ -168,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 ?? [], }), @@ -659,7 +662,6 @@ describe("V2 mini transport", () => { pending: { ses_1: [ { - admittedSeq: 1, id: "msg_queued", sessionID: "ses_1", timeCreated: 1, @@ -680,9 +682,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 +699,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 +728,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 +2696,6 @@ describe("V2 mini transport", () => { }) }) return ok({ - admittedSeq: 1, id: input.id ?? "msg_cmd", sessionID: "ses_1", type: "user" as const, 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", () => { 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 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": {