Просмотр исходного кода

chore: merge v2 into service channel config

Dax Raad 3 недель назад
Родитель
Сommit
e76b29c0b4
100 измененных файлов с 5565 добавлено и 1610 удалено
  1. 1 1
      .opencode/command/translate.md
  2. 36 179
      bun.lock
  3. 19 0
      infra/stage.ts
  4. 66 33
      nix/desktop.nix
  5. 4 4
      nix/hashes.json
  6. 132 5
      packages/ai/README.md
  7. 1 1
      packages/ai/package.json
  8. 7 3
      packages/ai/src/image-client.ts
  9. 89 39
      packages/ai/src/image.ts
  10. 2 2
      packages/ai/src/index.ts
  11. 314 0
      packages/ai/src/protocols/google-images.ts
  12. 147 18
      packages/ai/src/protocols/openai-chat.ts
  13. 181 119
      packages/ai/src/protocols/openai-images.ts
  14. 34 0
      packages/ai/src/protocols/utils/image-input.ts
  15. 1 1
      packages/ai/src/protocols/utils/lifecycle.ts
  16. 2 2
      packages/ai/src/protocols/utils/tool-stream.ts
  17. 202 0
      packages/ai/src/protocols/xai-images.ts
  18. 132 0
      packages/ai/src/protocols/zai-images.ts
  19. 11 1
      packages/ai/src/provider-error.ts
  20. 20 3
      packages/ai/src/providers/google.ts
  21. 1 0
      packages/ai/src/providers/index.ts
  22. 14 21
      packages/ai/src/providers/openai.ts
  23. 25 7
      packages/ai/src/providers/openrouter.ts
  24. 14 1
      packages/ai/src/providers/xai.ts
  25. 35 0
      packages/ai/src/providers/zai.ts
  26. 18 21
      packages/ai/test/auth-options.types.ts
  27. 3 7
      packages/ai/test/exports.test.ts
  28. BIN
      packages/ai/test/fixtures/images/edit-source.jpg
  29. 23 0
      packages/ai/test/fixtures/recordings/google-images/edits-an-image.json
  30. 27 0
      packages/ai/test/fixtures/recordings/google-images/generates-an-image.json
  31. 16 0
      packages/ai/test/fixtures/recordings/openai-images/edits-an-image.json
  32. 32 0
      packages/ai/test/fixtures/recordings/openrouter-reasoning-tool-loop.json
  33. 32 0
      packages/ai/test/fixtures/recordings/vercel-ai-gateway-reasoning-tool-loop.json
  34. 23 0
      packages/ai/test/fixtures/recordings/xai-images/edits-an-image.json
  35. 27 0
      packages/ai/test/fixtures/recordings/xai-images/generates-an-image.json
  36. 28 0
      packages/ai/test/fixtures/recordings/zai-images/generates-an-image.json
  37. 499 16
      packages/ai/test/image.test.ts
  38. 161 0
      packages/ai/test/image.types.ts
  39. 29 0
      packages/ai/test/lib/image.ts
  40. 24 2
      packages/ai/test/provider-error.test.ts
  41. 54 1
      packages/ai/test/provider/cloudflare.test.ts
  42. 56 0
      packages/ai/test/provider/google-images.recorded.test.ts
  43. 78 3
      packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts
  44. 430 0
      packages/ai/test/provider/openai-chat.test.ts
  45. 31 9
      packages/ai/test/provider/openai-images.recorded.test.ts
  46. 99 1
      packages/ai/test/provider/openrouter.test.ts
  47. 55 0
      packages/ai/test/provider/xai-images.recorded.test.ts
  48. 109 0
      packages/ai/test/provider/xai-images.test.ts
  49. 32 0
      packages/ai/test/provider/zai-images.recorded.test.ts
  50. 130 0
      packages/ai/test/provider/zai-images.test.ts
  51. 2 23
      packages/ai/test/recorded-scenarios.ts
  52. 27 0
      packages/ai/test/tool-stream.test.ts
  53. 9 0
      packages/ai/tsconfig.types.json
  54. 50 0
      packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts
  55. 3 6
      packages/app/e2e/regression/prompt-thinking-level.spec.ts
  56. 1 1
      packages/app/e2e/smoke/session-timeline.spec.ts
  57. 2 2
      packages/app/package.json
  58. 8 0
      packages/app/src/addons/serialize.test.ts
  59. 9 1
      packages/app/src/addons/serialize.ts
  60. 11 1
      packages/app/src/app.tsx
  61. 53 1
      packages/app/src/components/debug-bar.tsx
  62. 73 0
      packages/app/src/components/dialog-connect-provider.stories.tsx
  63. 425 30
      packages/app/src/components/dialog-connect-provider.tsx
  64. 2 2
      packages/app/src/components/dialog-custom-provider.tsx
  65. 55 0
      packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx
  66. 96 94
      packages/app/src/components/dialog-select-model-unpaid-v2.tsx
  67. 32 3
      packages/app/src/components/help-button.tsx
  68. 591 0
      packages/app/src/components/prompt-input-v2.tsx
  69. 300 889
      packages/app/src/components/prompt-input.tsx
  70. 1 1
      packages/app/src/components/prompt-input/attachments.ts
  71. 9 7
      packages/app/src/components/prompt-input/build-request-parts.ts
  72. 57 0
      packages/app/src/components/prompt-input/contracts.ts
  73. 47 0
      packages/app/src/components/prompt-input/history-store.ts
  74. 5 0
      packages/app/src/components/prompt-input/placeholder.ts
  75. 8 1
      packages/app/src/components/prompt-input/submit.test.ts
  76. 0 3
      packages/app/src/components/prompt-input/transient-state.ts
  77. 5 3
      packages/app/src/components/prompt-project-selector.tsx
  78. 30 18
      packages/app/src/components/prompt-workspace-selector.tsx
  79. 13 5
      packages/app/src/components/session/session-sortable-tab-v2.tsx
  80. 4 3
      packages/app/src/components/settings-v2/settings-v2.css
  81. 10 1
      packages/app/src/components/terminal.tsx
  82. 21 6
      packages/app/src/components/titlebar.tsx
  83. 8 1
      packages/app/src/context/notification.tsx
  84. 3 0
      packages/app/src/context/platform.tsx
  85. 2 1
      packages/app/src/context/prompt-state.ts
  86. 18 6
      packages/app/src/context/prompt.tsx
  87. 43 0
      packages/app/src/context/server-session.test.ts
  88. 12 1
      packages/app/src/context/server-session.ts
  89. 1 0
      packages/app/src/i18n/ar.ts
  90. 1 0
      packages/app/src/i18n/br.ts
  91. 1 0
      packages/app/src/i18n/bs.ts
  92. 1 0
      packages/app/src/i18n/da.ts
  93. 1 0
      packages/app/src/i18n/de.ts
  94. 3 0
      packages/app/src/i18n/en.ts
  95. 1 0
      packages/app/src/i18n/es.ts
  96. 1 0
      packages/app/src/i18n/fr.ts
  97. 1 0
      packages/app/src/i18n/ja.ts
  98. 1 0
      packages/app/src/i18n/ko.ts
  99. 1 0
      packages/app/src/i18n/no.ts
  100. 1 0
      packages/app/src/i18n/pl.ts

+ 1 - 1
.opencode/command/translate.md

@@ -1,6 +1,6 @@
 ---
 description: translate English to other languages
-model: opencode/claude-opus-4-8
+model: opencode/gpt-5.6-sol
 ---
 
 run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.

Разница между файлами не показана из-за своего большого размера
+ 36 - 179
bun.lock


+ 19 - 0
infra/stage.ts

@@ -8,6 +8,25 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
 export const awsStage = $app.stage === "production" ? "production" : "dev"
 export const deployAws = $app.stage === awsStage
 
+if ($app.stage === "production") {
+  new cloudflare.DnsRecord("TrustCenter", {
+    zoneId: zoneID,
+    name: "trust.opencode.ai",
+    type: "CNAME",
+    content: "3a69a5bb27875189.vercel-dns-016.com",
+    proxied: false,
+    ttl: 60,
+  })
+
+  new cloudflare.DnsRecord("TrustCenterVerification", {
+    zoneId: zoneID,
+    name: "opencode.ai",
+    type: "TXT",
+    content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
+    ttl: 60,
+  })
+}
+
 new cloudflare.RegionalHostname("RegionalHostname", {
   hostname: domain,
   regionKey: "us",

+ 66 - 33
nix/desktop.nix

@@ -8,6 +8,8 @@
   makeWrapper,
   writableTmpDirAsHomeHook,
   autoPatchelfHook,
+  copyDesktopItems,
+  makeDesktopItem,
   opencode,
 }:
 let
@@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: {
     nodejs
     makeWrapper
     writableTmpDirAsHomeHook
-  ] ++ lib.optionals stdenv.hostPlatform.isLinux [
+  ]
+  ++ lib.optionals stdenv.hostPlatform.isLinux [
     autoPatchelfHook
-  ] ++ lib.optionals stdenv.hostPlatform.isDarwin [
+    copyDesktopItems
+  ]
+  ++ lib.optionals stdenv.hostPlatform.isDarwin [
     # Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
     darwin.autoSignDarwinBinariesHook
   ];
@@ -38,20 +43,37 @@ stdenv.mkDerivation (finalAttrs: {
     (lib.getLib stdenv.cc.cc)
   ];
 
+  desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
+    name = "ai.opencode.desktop";
+    desktopName = "OpenCode";
+    exec = "opencode-desktop %U";
+    icon = "ai.opencode.desktop";
+    # Electron 41 derives X11 WM_CLASS from app.name.
+    startupWMClass = "OpenCode";
+    categories = [ "Development" ];
+  });
+
   env = opencode.env // {
     ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
   };
 
-  # https://github.com/electron/electron/issues/31121
-  # mac builds use a .app bundle which doesnt have this issue
-  postPatch = lib.optionalString stdenv.isLinux ''
-    BASE_PATH=packages/desktop
-    FILES=(src/main/windows.ts)
-    for file in "''${FILES[@]}"; do
-      substituteInPlace $BASE_PATH/$file \
-        --replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
-    done
-  '';
+  postPatch =
+    # NOTE: Relax Bun version check to be a warning instead of an error
+    ''
+      substituteInPlace packages/script/src/index.ts \
+        --replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
+                       'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
+    ''
+    # https://github.com/electron/electron/issues/31121
+    # mac builds use a .app bundle which doesnt have this issue
+    + lib.optionalString stdenv.isLinux ''
+      BASE_PATH=packages/desktop
+      FILES=(src/main/windows.ts)
+      for file in "''${FILES[@]}"; do
+        substituteInPlace $BASE_PATH/$file \
+          --replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
+      done
+    '';
 
   preBuild = ''
     cp -r "${electron.dist}" $HOME/.electron-dist
@@ -76,27 +98,38 @@ stdenv.mkDerivation (finalAttrs: {
     runHook postBuild
   '';
 
-  installPhase =
-    ''
-      runHook preInstall
-    ''
-    + lib.optionalString stdenv.hostPlatform.isDarwin ''
-      mkdir -p $out/Applications
-      mv dist/mac*/*.app $out/Applications
-      makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
-    ''
-    + lib.optionalString stdenv.hostPlatform.isLinux ''
-      mkdir -p $out/opt/opencode-desktop
-      cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
-      makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
-        --inherit-argv0 \
-        --set ELECTRON_FORCE_IS_PACKAGED 1 \
-        --add-flags $out/opt/opencode-desktop/resources/app.asar \
-        --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
-    ''
-    + ''
-      runHook postInstall
-    '';
+  installPhase = ''
+    runHook preInstall
+  ''
+  + lib.optionalString stdenv.hostPlatform.isDarwin ''
+    mkdir -p $out/Applications
+    mv dist/mac*/*.app $out/Applications
+    makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
+  ''
+  + lib.optionalString stdenv.hostPlatform.isLinux ''
+    mkdir -p $out/opt/opencode-desktop
+    cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
+    install -Dm644 resources/icons/32x32.png \
+      "$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
+    install -Dm644 resources/icons/64x64.png \
+      "$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
+    install -Dm644 resources/icons/128x128.png \
+      "$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png"
+    install -Dm644 resources/icons/128x128@2x.png \
+      "$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png"
+    install -Dm644 resources/icons/icon.png \
+      "$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png"
+    install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
+      "$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
+    makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
+     --inherit-argv0 \
+     --set ELECTRON_FORCE_IS_PACKAGED 1 \
+     --add-flags $out/opt/opencode-desktop/resources/app.asar \
+     --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
+  ''
+  + ''
+    runHook postInstall
+  '';
 
   autoPatchelfIgnoreMissingDeps = [
     "libc.musl-x86_64.so.1"

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
-    "aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
-    "aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
-    "x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
+    "x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=",
+    "aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=",
+    "aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=",
+    "x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo="
   }
 }

+ 132 - 5
packages/ai/README.md

@@ -29,22 +29,149 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
 Use `Image.generate` with an image model for direct asset generation:
 
 ```ts
-import { Image } from "@opencode-ai/ai"
+import { Image, ImageInput } from "@opencode-ai/ai"
 import { OpenAI } from "@opencode-ai/ai/providers"
 
 const program = Effect.gen(function* () {
   const response = yield* Image.generate({
     model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
     prompt: "A robot tending a rooftop garden",
-    count: 2,
-    size: { width: 1024, height: 1024 },
-    providerOptions: { openai: { quality: "high", outputFormat: "webp" } },
+    options: {
+      n: 2,
+      size: "1024x1024",
+      quality: "high", // inferred from the OpenAI image model
+      outputFormat: "webp",
+      future_option: true, // unknown native options pass through unchanged
+    },
   })
 
   return response.images // GeneratedImage[] with owned bytes or a provider URL
 })
 ```
 
+Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
+
+```ts
+const response =
+  yield *
+  Image.generate({
+    model,
+    prompt: "Combine these product photos into one studio scene",
+    images: [
+      ImageInput.bytes(firstBytes, "image/png"),
+      ImageInput.url("https://example.com/second.webp"),
+      ImageInput.file("file_123"),
+    ],
+    options,
+    http,
+  })
+```
+
+`ImageInput.fileUri(uri, mediaType)` represents provider file URIs such as Gemini Files. Raw strings are not
+accepted as image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images`
+uses text-to-image generation; a non-empty array selects the provider's edit behavior without enforcing provider
+image-count limits locally. `images` is the only common image-editing field. OpenAI uses multipart for byte/data-URL
+edits and its JSON reference body for URL or file-ID edits. Its provider-specific `options.mask` accepts an
+`ImageInput` for inpainting:
+
+```ts
+yield *
+  Image.generate({
+    model: OpenAI.configure({ apiKey }).image("gpt-image-2"),
+    prompt,
+    images: [ImageInput.bytes(sourceBytes, "image/png")],
+    options: { mask: ImageInput.bytes(maskBytes, "image/png") },
+  })
+```
+
+The OpenAI adapter extracts this helper value into the edit request's native `mask` field rather than passing the
+tagged `ImageInput` object through as an ordinary option. On multipart requests, `http.body` can override option
+fields but not structural `model`, `prompt`, `image[]`, or `mask` fields, and the transport owns the multipart
+`Content-Type` boundary. For JSON requests, `http.body` remains the final raw-native overlay. Gemini does not fetch
+public HTTP URLs, and hosted Z.ai image generation does not accept image inputs. These cases fail with
+`InvalidRequest` before network I/O.
+
+Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
+
+```ts
+const model = OpenAI.configure({ apiKey }).image("gpt-image-2")
+
+yield *
+  Image.generate({
+    model,
+    prompt,
+    options: { quality: "medium" },
+    http,
+  })
+```
+
+xAI image models use the same request API with xAI-native controls:
+
+```ts
+yield *
+  Image.generate({
+    model: XAI.configure({ apiKey }).image("any-model-id"),
+    prompt,
+    options: {
+      n: 2,
+      aspectRatio: "16:9",
+      resolution: "1k",
+      responseFormat: "b64_json",
+      future_option: true,
+    },
+    http,
+  })
+```
+
+Google's current Gemini image models use the same direct API:
+
+```ts
+import { Google } from "@opencode-ai/ai/providers"
+
+const googleProgram = Effect.gen(function* () {
+  const response = yield* Image.generate({
+    model: Google.configure({ apiKey }).image("any-model-id"),
+    prompt: "A robot tending a rooftop garden",
+    options: {
+      aspectRatio: "16:9",
+      imageSize: "2K",
+      seed: 42,
+      thinkingLevel: "HIGH",
+      includeThoughts: true,
+      futureOption: true,
+    },
+    http,
+  })
+
+  return response.images
+})
+```
+
+Google image options are request-scoped and inferred from the selected model. Known fields autocomplete while
+future string values and arbitrary native Gemini `generationConfig` fields remain available. Native fields override
+their mapped aliases, and `http.body` is the final deep overlay. The selected model ID is sent to Gemini
+`generateContent` without a local allowlist.
+
+Z.ai image models infer open Z.ai-native options from the selected model:
+
+```ts
+yield *
+  Image.generate({
+    model: ZAI.configure({ apiKey }).image("any-model-id"),
+    prompt,
+    options: {
+      quality: "hd",
+      userID: "user-123",
+      future_option: true,
+    },
+    http,
+  })
+```
+
+Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
+`application/octet-stream`. Output URLs expire after 30 days; download and persist them promptly if they must
+remain available.
+
 Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
 
 ```ts
@@ -145,7 +272,7 @@ const gateway = CloudflareAIGateway.configure({
 }).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
 ```
 
-Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
+Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
 
 ### Package-like entrypoints
 

+ 1 - 1
packages/ai/package.json

@@ -7,7 +7,7 @@
   "scripts": {
     "setup:recording-env": "bun run script/setup-recording-env.ts",
     "test": "bun test --timeout 30000 --only-failures",
-    "typecheck": "tsgo --noEmit",
+    "typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.types.json",
     "build": "tsc -p tsconfig.build.json"
   },
   "files": [

+ 7 - 3
packages/ai/src/image-client.ts

@@ -1,17 +1,21 @@
 import { Context, Effect, Layer } from "effect"
 import { RequestExecutor } from "./route/executor"
-import type { ImageRequest, ImageResponse } from "./image"
+import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image"
 import type { LLMError } from "./schema"
 
 export type Execute = RequestExecutor.Interface["execute"]
 
 export interface Interface {
-  readonly generate: (request: ImageRequest) => Effect.Effect<ImageResponse, LLMError>
+  readonly generate: <Options extends ImageOptions>(
+    request: ImageRequestFor<Options>,
+  ) => Effect.Effect<ImageResponse, LLMError>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
 
-export const generate = (request: ImageRequest): Effect.Effect<ImageResponse, LLMError> =>
+export const generate = <Options extends ImageOptions>(
+  request: ImageRequestFor<Options>,
+): Effect.Effect<ImageResponse, LLMError> =>
   Effect.gen(function* () {
     const client = yield* Service
     return yield* client.generate(request)

+ 89 - 39
packages/ai/src/image.ts

@@ -1,80 +1,120 @@
 import { Effect, Schema } from "effect"
 import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
-import { ImageClient, type Execute as ImageExecute } from "./image-client"
+import { ImageClient, Service, type Execute as ImageExecute } from "./image-client"
 
-export interface ImageRoute {
+export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
   readonly id: string
-  readonly generate: (request: ImageRequest, execute: ImageExecute) => Effect.Effect<ImageResponse, LLMError>
+  readonly generate: (
+    request: ImageRequestFor<Options>,
+    execute: ImageExecute,
+  ) => Effect.Effect<ImageResponse, LLMError>
 }
 
-export class ImageModel {
+export type ImageOptions = Record<string, unknown>
+
+export class ImageModel<Options extends ImageOptions = ImageOptions> {
+  declare protected readonly _Options: (options: Options) => Options
   readonly id: ModelID
   readonly provider: ProviderID
-  readonly route: ImageRoute
-  readonly defaults?: ImageModelDefaults
+  readonly route: ImageRoute<Options>
+  readonly http?: HttpOptions
 
-  constructor(input: ImageModel.Input) {
+  constructor(input: ImageModel.Input<Options>) {
     this.id = input.id
     this.provider = input.provider
     this.route = input.route
-    this.defaults = input.defaults
+    this.http = input.http
   }
 
-  static make(input: ImageModel.MakeInput) {
-    return new ImageModel({
+  static make<Options extends ImageOptions = ImageOptions>(input: ImageModel.MakeInput<Options>) {
+    return new ImageModel<Options>({
       id: ModelID.make(input.id),
       provider: ProviderID.make(input.provider),
       route: input.route,
-      defaults: input.defaults,
+      http: input.http,
     })
   }
 }
 
 export namespace ImageModel {
-  export interface Input {
+  export interface Input<Options extends ImageOptions = ImageOptions> {
     readonly id: ModelID
     readonly provider: ProviderID
-    readonly route: ImageRoute
-    readonly defaults?: ImageModelDefaults
+    readonly route: ImageRoute<Options>
+    readonly http?: HttpOptions
   }
 
-  export interface MakeInput extends Omit<Input, "id" | "provider"> {
+  export interface MakeInput<Options extends ImageOptions = ImageOptions>
+    extends Omit<Input<Options>, "id" | "provider"> {
     readonly id: string | ModelID
     readonly provider: string | ProviderID
   }
 }
 
-export interface ImageModelDefaults {
-  readonly providerOptions?: Record<string, Record<string, unknown>>
-  readonly http?: HttpOptions
-}
-
 export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
   expected: "Image.Model",
 })
 
-export const ImageSize = Schema.Struct({
-  width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
-  height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
-}).annotate({ identifier: "Image.Size" })
-export type ImageSize = Schema.Schema.Type<typeof ImageSize>
+const ImageBytesInput = Schema.Struct({
+  type: Schema.Literal("bytes"),
+  data: Schema.Uint8Array,
+  mediaType: Schema.String,
+})
+const ImageUrlInput = Schema.Struct({
+  type: Schema.Literal("url"),
+  url: Schema.String,
+})
+const ImageFileIDInput = Schema.Struct({
+  type: Schema.Literal("file-id"),
+  id: Schema.String,
+})
+const ImageFileURIInput = Schema.Struct({
+  type: Schema.Literal("file-uri"),
+  uri: Schema.String,
+  mediaType: Schema.String,
+})
+
+export const ImageInputSchema = Schema.Union([
+  ImageBytesInput,
+  ImageUrlInput,
+  ImageFileIDInput,
+  ImageFileURIInput,
+]).pipe(Schema.toTaggedUnion("type"))
+export type ImageInput = Schema.Schema.Type<typeof ImageInputSchema>
+
+export const ImageInput = {
+  bytes: (data: Uint8Array, mediaType: string): ImageInput => ({ type: "bytes", data, mediaType }),
+  url: (url: string): ImageInput => ({ type: "url", url }),
+  file: (id: string): ImageInput => ({ type: "file-id", id }),
+  fileUri: (uri: string, mediaType: string): ImageInput => ({ type: "file-uri", uri, mediaType }),
+} as const
 
 export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
   model: ImageModelSchema,
   prompt: Schema.String,
-  count: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
-  size: Schema.optional(ImageSize),
-  aspectRatio: Schema.optional(Schema.String),
-  seed: Schema.optional(Schema.Number),
-  providerOptions: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))),
+  images: Schema.optional(Schema.Array(ImageInputSchema)),
+  options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
   http: Schema.optional(HttpOptions),
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
-}) {}
+}) {
+  declare protected readonly _ImageRequest: void
+}
 
-export type ImageRequestInput = Omit<ConstructorParameters<typeof ImageRequest>[0], "http"> & {
-  readonly http?: HttpOptions.Input
+export type ImageRequestFor<Options extends ImageOptions = ImageOptions> = Omit<ImageRequest, "model" | "options"> & {
+  readonly model: ImageModel<Options>
+  readonly options?: Options
 }
 
+export type ImageModelOptions<Model> = Model extends ImageModel<infer Options> ? Options : never
+
+export type ImageRequestInput<Model extends object = ImageModel> = Omit<
+  ConstructorParameters<typeof ImageRequest>[0],
+  "model" | "options" | "http"
+> & {
+  readonly model: Model
+  readonly options?: NoInfer<ImageModelOptions<Model>>
+  readonly http?: HttpOptions.Input
+} & (Model extends ImageModel<ImageModelOptions<Model>> ? unknown : never)
+
 export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
   mediaType: Schema.String,
   data: Schema.Union([Schema.String, Schema.Uint8Array]),
@@ -91,24 +131,34 @@ export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")
   }
 }
 
-export const request = (input: ImageRequest | ImageRequestInput) => {
+export function request<const Model extends object>(
+  input: ImageRequestInput<Model>,
+): ImageRequestFor<ImageModelOptions<Model>>
+export function request(input: ImageRequest): ImageRequest
+export function request(input: ImageRequest | ImageRequestInput) {
   if (input instanceof ImageRequest) return input
   return new ImageRequest({
     ...input,
+    model: input.model as unknown as ImageModel,
     http: input.http === undefined ? undefined : HttpOptions.make(input.http),
   })
 }
 
-export const generate = (input: ImageRequest | ImageRequestInput) =>
-  Effect.try({
-    try: () => request(input),
+export function generate<const Model extends object>(
+  input: ImageRequestInput<Model>,
+): Effect.Effect<ImageResponse, LLMError, Service>
+export function generate(input: ImageRequest): Effect.Effect<ImageResponse, LLMError, Service>
+export function generate(input: ImageRequest | ImageRequestInput) {
+  return Effect.try({
+    try: () => (input instanceof ImageRequest ? input : request(input)),
     catch: (error) =>
       new LLMError({
         module: "Image",
         method: "generate",
         reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
       }),
-  }).pipe(Effect.flatMap(ImageClient.generate))
+  }).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
+}
 
 export const Image = {
   request,

+ 2 - 2
packages/ai/src/index.ts

@@ -11,8 +11,8 @@ export type {
   Service as LLMClientService,
 } from "./route/client"
 export * from "./schema"
-export { GeneratedImage, ImageModel, ImageRequest, ImageResponse, ImageSize } from "./image"
-export type { ImageModelDefaults, ImageRequestInput, ImageRoute } from "./image"
+export { GeneratedImage, ImageInput, ImageInputSchema, ImageModel, ImageRequest, ImageResponse } from "./image"
+export type { ImageModelOptions, ImageOptions, ImageRequestFor, ImageRequestInput, ImageRoute } from "./image"
 export { Image } from "./image"
 export { Tool, ToolFailure, toDefinitions } from "./tool"
 export { ToolRuntime } from "./tool-runtime"

+ 314 - 0
packages/ai/src/protocols/google-images.ts

@@ -0,0 +1,314 @@
+import { Effect, Encoding, Schema } from "effect"
+import { Headers, HttpClientRequest } from "effect/unstable/http"
+import {
+  GeneratedImage,
+  ImageModel,
+  ImageResponse,
+  type ImageInput,
+  type ImageRequestFor,
+  type ImageRoute,
+} from "../image"
+import { Auth, type Definition as AuthDefinition } from "../route/auth"
+import {
+  InvalidProviderOutputReason,
+  LLMError,
+  Usage,
+  mergeHttpOptions,
+  mergeJsonRecords,
+  type HttpOptions,
+  type ProviderMetadata,
+} from "../schema"
+import { ProviderShared } from "./shared"
+import { ImageInputs } from "./utils/image-input"
+
+const ADAPTER = "google-images"
+export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
+
+export type GoogleImageString<Known extends string> = Known | (string & {})
+
+export type GoogleImageOptions = {
+  readonly aspectRatio?: GoogleImageString<
+    "1:1" | "2:3" | "3:2" | "3:4" | "4:3" | "4:5" | "5:4" | "9:16" | "16:9" | "21:9"
+  >
+  readonly imageSize?: GoogleImageString<"1K" | "2K" | "4K">
+  readonly seed?: number
+  readonly thinkingLevel?: GoogleImageString<"MINIMAL" | "LOW" | "MEDIUM" | "HIGH">
+  readonly includeThoughts?: boolean
+} & Record<string, unknown>
+
+export type GoogleImageBody = Record<string, unknown> & {
+  readonly contents: ReadonlyArray<{
+    readonly role: "user"
+    readonly parts: ReadonlyArray<Record<string, unknown>>
+  }>
+  readonly generationConfig: Record<string, unknown>
+}
+
+const GoogleUsage = Schema.StructWithRest(
+  Schema.Struct({
+    cachedContentTokenCount: Schema.optional(Schema.Number),
+    thoughtsTokenCount: Schema.optional(Schema.Number),
+    promptTokenCount: Schema.optional(Schema.Number),
+    candidatesTokenCount: Schema.optional(Schema.Number),
+    totalTokenCount: Schema.optional(Schema.Number),
+    promptTokensDetails: Schema.optional(Schema.Unknown),
+    candidatesTokensDetails: Schema.optional(Schema.Unknown),
+  }),
+  [Schema.Record(Schema.String, Schema.Unknown)],
+)
+
+const GoogleImageResponse = Schema.Struct({
+  candidates: Schema.optional(
+    Schema.Array(
+      Schema.Struct({
+        index: Schema.optional(Schema.Number),
+        content: Schema.optional(
+          Schema.Struct({
+            parts: Schema.Array(
+              Schema.Struct({
+                text: Schema.optional(Schema.String),
+                thought: Schema.optional(Schema.Boolean),
+                thoughtSignature: Schema.optional(Schema.String),
+                inlineData: Schema.optional(
+                  Schema.Struct({
+                    mimeType: Schema.String,
+                    data: Schema.String,
+                  }),
+                ),
+              }),
+            ),
+          }),
+        ),
+        finishReason: Schema.optional(Schema.String),
+        finishMessage: Schema.optional(Schema.String),
+        safetyRatings: Schema.optional(Schema.Unknown),
+        citationMetadata: Schema.optional(Schema.Unknown),
+        groundingMetadata: Schema.optional(Schema.Unknown),
+      }),
+    ),
+  ),
+  usageMetadata: Schema.optional(GoogleUsage),
+  modelVersion: Schema.optional(Schema.String),
+  responseId: Schema.optional(Schema.String),
+  promptFeedback: Schema.optional(Schema.Unknown),
+})
+
+export interface ModelInput {
+  readonly id: string
+  readonly auth: AuthDefinition
+  readonly baseURL?: string
+  readonly headers?: Record<string, string>
+  readonly http?: HttpOptions
+}
+
+const nativeOptions = (options: GoogleImageOptions | undefined) => {
+  const { aspectRatio, imageSize, seed, thinkingLevel, includeThoughts, ...native } = options ?? {}
+  const image = {
+    aspectRatio,
+    imageSize,
+  }
+  const thinkingConfig = {
+    thinkingLevel,
+    includeThoughts,
+  }
+  return (
+    mergeJsonRecords(
+      {
+        responseModalities: ["IMAGE"],
+        imageConfig: Object.values(image).some((value) => value !== undefined) ? image : undefined,
+        seed,
+        thinkingConfig: Object.values(thinkingConfig).some((value) => value !== undefined) ? thinkingConfig : undefined,
+      },
+      native,
+    ) ?? { responseModalities: ["IMAGE"] }
+  )
+}
+
+const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
+  new LLMError({
+    module: ADAPTER,
+    method: "generate",
+    reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }),
+  })
+
+const applyQuery = (url: string, query: Record<string, string> | undefined) => {
+  if (!query) return url
+  const next = new URL(url)
+  Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
+  return next.toString()
+}
+
+export const model = (input: ModelInput) => {
+  const route: ImageRoute<GoogleImageOptions> = {
+    id: ADAPTER,
+    generate: Effect.fn("GoogleImages.generate")(function* (request: ImageRequestFor<GoogleImageOptions>, execute) {
+      const imageParts = yield* Effect.forEach(request.images ?? [], googleImagePart)
+      const http = mergeHttpOptions(request.model.http, request.http)
+      const requestBody = mergeJsonRecords(
+        {
+          contents: [{ role: "user", parts: [{ text: request.prompt }, ...imageParts] }],
+          generationConfig: nativeOptions(request.options),
+        },
+        http?.body,
+      ) as GoogleImageBody
+      const text = ProviderShared.encodeJson(requestBody)
+      const url = applyQuery(
+        `${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/models/${request.model.id}:generateContent`,
+        http?.query,
+      )
+      const headers = yield* Auth.toEffect(input.auth)({
+        request,
+        method: "POST",
+        url,
+        body: text,
+        headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
+      })
+      const response = yield* execute(
+        HttpClientRequest.post(url).pipe(
+          HttpClientRequest.setHeaders(headers),
+          HttpClientRequest.bodyText(text, "application/json"),
+        ),
+      )
+      const payload = yield* response.json.pipe(
+        Effect.mapError(() => invalidOutput("Failed to read the Google Images response")),
+      )
+      const decoded = yield* Schema.decodeUnknownEffect(GoogleImageResponse)(payload).pipe(
+        Effect.mapError(() => invalidOutput("Google Images returned an invalid response")),
+      )
+      const candidates = decoded.candidates ?? []
+      const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
+        index: candidate.index ?? candidateIndex,
+        finishReason: candidate.finishReason,
+        finishMessage: candidate.finishMessage,
+        safetyRatings: candidate.safetyRatings,
+        citationMetadata: candidate.citationMetadata,
+        groundingMetadata: candidate.groundingMetadata,
+        parts: (candidate.content?.parts ?? []).map((part) =>
+          part.inlineData === undefined
+            ? {
+                type: "text",
+                text: part.text,
+                thought: part.thought,
+                thoughtSignature: part.thoughtSignature,
+              }
+            : {
+                type: "inlineData",
+                mediaType: part.inlineData.mimeType,
+                thought: part.thought,
+                thoughtSignature: part.thoughtSignature,
+              },
+        ),
+      }))
+      const encoded = candidates.flatMap((candidate, candidateIndex) =>
+        (candidate.content?.parts ?? []).flatMap((part, partIndex) =>
+          part.inlineData === undefined || part.thought === true
+            ? []
+            : [{ candidate, candidateIndex, partIndex, inlineData: part.inlineData }],
+        ),
+      )
+      const images = yield* Effect.forEach(encoded, (item) =>
+        Effect.fromResult(Encoding.decodeBase64(item.inlineData.data)).pipe(
+          Effect.mapError(() =>
+            invalidOutput(
+              `Google Images candidate ${item.candidateIndex} part ${item.partIndex} contains invalid base64 data`,
+            ),
+          ),
+          Effect.map(
+            (data) =>
+              new GeneratedImage({
+                mediaType: item.inlineData.mimeType,
+                data,
+                providerMetadata: {
+                  google: {
+                    candidateIndex: item.candidate.index ?? item.candidateIndex,
+                    partIndex: item.partIndex,
+                    finishReason: item.candidate.finishReason,
+                    safetyRatings: item.candidate.safetyRatings,
+                    citationMetadata: item.candidate.citationMetadata,
+                    groundingMetadata: item.candidate.groundingMetadata,
+                    thoughtSignature: item.candidate.content?.parts[item.partIndex]?.thoughtSignature,
+                  },
+                },
+              }),
+          ),
+        ),
+      )
+      if (images.length === 0) {
+        const finishReasons = candidates.flatMap((candidate) =>
+          candidate.finishReason === undefined ? [] : [candidate.finishReason],
+        )
+        return yield* invalidOutput(
+          `Google Images returned no final images${
+            finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
+          }; inspect reason.providerMetadata.google for prompt feedback and candidate details`,
+          {
+            google: {
+              promptFeedback: decoded.promptFeedback,
+              candidates: candidateMetadata,
+            },
+          },
+        )
+      }
+      const usage = decoded.usageMetadata
+      const outputTokens =
+        usage?.candidatesTokenCount === undefined
+          ? undefined
+          : usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0)
+      return new ImageResponse({
+        images,
+        usage:
+          usage === undefined
+            ? undefined
+            : new Usage({
+                inputTokens: usage.promptTokenCount,
+                outputTokens,
+                nonCachedInputTokens: ProviderShared.subtractTokens(
+                  usage.promptTokenCount,
+                  usage.cachedContentTokenCount,
+                ),
+                cacheReadInputTokens: usage.cachedContentTokenCount,
+                reasoningTokens: usage.thoughtsTokenCount,
+                totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
+                providerMetadata: { google: usage },
+              }),
+        providerMetadata: {
+          google: {
+            modelVersion: decoded.modelVersion,
+            responseId: decoded.responseId,
+            promptFeedback: decoded.promptFeedback,
+            candidates: candidateMetadata,
+          },
+        },
+      })
+    }),
+  }
+  return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http })
+}
+
+const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, LLMError> => {
+  if (image.type === "bytes")
+    return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
+  if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
+  if (image.type === "url")
+    return ImageInputs.decodeDataUrl(image.url, ADAPTER).pipe(
+      Effect.flatMap((decoded) => {
+        if (decoded === undefined)
+          return Effect.fail(
+            ImageInputs.invalid(
+              ADAPTER,
+              "Google generateContent does not fetch public image URLs; use bytes, a data URL, or a Gemini file URI",
+            ),
+          )
+        return Effect.succeed({
+          inlineData: { mimeType: decoded.mediaType, data: Encoding.encodeBase64(decoded.data) },
+        })
+      }),
+    )
+  return Effect.fail(
+    ImageInputs.invalid(ADAPTER, "Google generateContent requires Gemini file URIs rather than provider file IDs"),
+  )
+}
+
+export const GoogleImages = {
+  model,
+} as const

+ 147 - 18
packages/ai/src/protocols/openai-chat.ts

@@ -77,6 +77,7 @@ const OpenAIChatMessage = Schema.Union([
     reasoning_content: Schema.optional(Schema.String),
     reasoning: Schema.optional(Schema.String),
     reasoning_text: Schema.optional(Schema.String),
+    reasoning_details: optionalArray(Schema.Unknown),
   }),
   Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
 ]).pipe(Schema.toTaggedUnion("role"))
@@ -149,6 +150,7 @@ const OpenAIChatDelta = Schema.Struct({
   reasoning_content: optionalNull(Schema.String),
   reasoning: optionalNull(Schema.String),
   reasoning_text: optionalNull(Schema.String),
+  reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
   tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
 })
 
@@ -164,13 +166,23 @@ export const OpenAIChatEvent = Schema.Struct({
 export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
 type OpenAIChatRequestMessage = LLMRequest["messages"][number]
 
+interface PendingToolDelta {
+  readonly id?: string
+  readonly name?: string
+  readonly input: string
+}
+
 export interface ParserState {
   readonly tools: ToolStream.State<number>
+  readonly pendingTools: Partial<Record<number, PendingToolDelta>>
   readonly toolCallEvents: ReadonlyArray<LLMEvent>
   readonly usage?: Usage
   readonly finishReason?: FinishReason
   readonly lifecycle: Lifecycle.State
   readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
+  readonly reasoningDetails: Array<unknown>
+  readonly reasoningDetailsObserved: boolean
+  readonly reasoningEmitted: boolean
 }
 
 // =============================================================================
@@ -216,7 +228,15 @@ const openAICompatibleReasoningContent = (native: unknown) =>
 const reasoningField = (part: ReasoningPart) => {
   const field = part.providerMetadata?.openai?.reasoningField
   if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
-  return "reasoning_content"
+}
+
+const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
+  const observed = parts.flatMap((part) => {
+    const details = part.providerMetadata?.openai?.reasoningDetails
+    return Array.isArray(details) ? details : []
+  })
+  if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
+  if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
 }
 
 const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
@@ -260,19 +280,28 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
     }
   }
   const text = reasoning.map((part) => part.text).join("")
-  const field = reasoning[0] ? reasoningField(reasoning[0]) : "reasoning_content"
+  const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
+  const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
+  const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
+  const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
+  const field = (() => {
+    if (reasoning.length === 0) return
+    if (observedField !== undefined) return observedField
+    if (nativeReasoning !== undefined) return "reasoning_content"
+    if (!fullyStructured) return "reasoning_content"
+  })()
+  const reasoningContent = (() => {
+    if (reasoning.length === 0) return nativeReasoning
+    if (field === "reasoning_content") return text
+  })()
   return {
     role: "assistant" as const,
     content: content.length === 0 ? null : ProviderShared.joinText(content),
     tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
-    reasoning_content:
-      reasoning.length === 0
-        ? openAICompatibleReasoningContent(message.native?.openaiCompatible)
-        : field === "reasoning_content"
-          ? text
-          : undefined,
+    reasoning_content: reasoningContent,
     reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
     reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
+    reasoning_details: details,
   }
 })
 
@@ -423,6 +452,59 @@ const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null
   if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
 }
 
+const detailText = (details: ReadonlyArray<unknown>) => {
+  const text = details.flatMap((detail) => {
+    if (!isRecord(detail)) return []
+    if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
+    if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
+      return [detail.summary]
+    return []
+  })
+  if (text.length > 0) return text.join("")
+}
+
+const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
+  for (const detail of details) {
+    const previous = result.at(-1)
+    if (
+      !isRecord(previous) ||
+      previous.type !== "reasoning.text" ||
+      !isRecord(detail) ||
+      detail.type !== "reasoning.text" ||
+      conflictingReasoningTextDetails(previous, detail)
+    ) {
+      result.push(detail)
+      continue
+    }
+    result[result.length - 1] = {
+      ...previous,
+      ...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
+      text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
+      signature: mergeDetailValue(previous.signature, detail.signature),
+      format: mergeDetailValue(previous.format, detail.format),
+    }
+  }
+}
+
+const mergeDetailValue = (previous: unknown, current: unknown) =>
+  previous || current || (previous !== undefined ? previous : current)
+
+const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
+  conflictingDetailValue(previous.id, current.id) ||
+  conflictingDetailValue(previous.index, current.index) ||
+  conflictingDetailValue(previous.format, current.format) ||
+  (Boolean(previous.signature) && Boolean(current.signature) && previous.signature !== current.signature)
+
+const conflictingDetailValue = (previous: unknown, current: unknown) =>
+  previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
+
+const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
+  openai: {
+    ...(field ? { reasoningField: field } : {}),
+    ...(details ? { reasoningDetails: details } : {}),
+  },
+})
+
 const step = (state: ParserState, event: OpenAIChatEvent) =>
   Effect.gen(function* () {
     const events: LLMEvent[] = []
@@ -432,29 +514,56 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
     const delta = choice?.delta
     const toolDeltas = delta?.tool_calls ?? []
     let tools = state.tools
+    let pendingTools = state.pendingTools
 
     let lifecycle = state.lifecycle
 
     const reasoning = reasoningDelta(delta)
-    const reasoningField = state.reasoningField ?? reasoning?.field
-    if (reasoning)
-      lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning.text, {
-        openai: { reasoningField: reasoningField ?? reasoning.field },
-      })
+    const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
+    const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
+    if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
+    const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
+    const deltaMetadata = reasoningMetadata(reasoningField)
+    const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
+    if (!state.lifecycle.text.has("text-0") && text !== undefined)
+      lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
+    else if (
+      reasoningDetailsObserved &&
+      !lifecycle.reasoning.has("reasoning-0") &&
+      (Boolean(delta?.content) || toolDeltas.length > 0)
+    )
+      lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
+    const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
 
     if (delta?.content) {
-      lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
+      lifecycle = Lifecycle.reasoningEnd(
+        lifecycle,
+        events,
+        "reasoning-0",
+        reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
+      )
       lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
     }
 
-    if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
-
     for (const tool of toolDeltas) {
+      const current = tools[tool.index]
+      const pending = pendingTools[tool.index]
+      const id = current?.id ?? pending?.id ?? (tool.id || undefined)
+      const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
+      const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
+      if (!current && (!id || !name)) {
+        pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
+        continue
+      }
+      if (pending) {
+        pendingTools = { ...pendingTools }
+        delete pendingTools[tool.index]
+      }
       const result = ToolStream.appendOrStart(
         ADAPTER,
         tools,
         tool.index,
-        { id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" },
+        { id: id || undefined, name: name || undefined, text },
         "OpenAI Chat tool call delta is missing id or name",
       )
       if (ToolStream.isError(result)) return yield* result
@@ -463,6 +572,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
       events.push(...result.events)
     }
 
+    if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
+      return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
+
     // Finalize accumulated tool inputs eagerly when finish_reason arrives so
     // valid calls and malformed local calls settle independently.
     const finished =
@@ -473,11 +585,15 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
     return [
       {
         tools: finished?.tools ?? tools,
+        pendingTools,
         toolCallEvents: finished?.events ?? state.toolCallEvents,
         usage,
         finishReason,
         lifecycle,
         reasoningField,
+        reasoningDetails: state.reasoningDetails,
+        reasoningDetailsObserved,
+        reasoningEmitted,
       },
       events,
     ] as const
@@ -487,7 +603,16 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
   const events: LLMEvent[] = []
   const hasToolCalls = state.toolCallEvents.length > 0
   const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
-  const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
+  const metadata = reasoningMetadata(
+    state.reasoningField,
+    state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
+  )
+  const started =
+    state.reasoningDetailsObserved && !state.reasoningEmitted
+      ? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
+      : state.lifecycle
+  const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
+  const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
   events.push(...state.toolCallEvents)
   if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
   return events
@@ -512,9 +637,13 @@ export const protocol = Protocol.make({
     event: Protocol.jsonEvent(OpenAIChatEvent),
     initial: () => ({
       tools: ToolStream.empty<number>(),
+      pendingTools: {},
       toolCallEvents: [],
       lifecycle: Lifecycle.initial(),
       reasoningField: undefined,
+      reasoningDetails: [],
+      reasoningDetailsObserved: false,
+      reasoningEmitted: false,
     }),
     step,
     onHalt: finishEvents,

+ 181 - 119
packages/ai/src/protocols/openai-images.ts

@@ -1,42 +1,50 @@
 import { Effect, Encoding, Schema } from "effect"
-import { Headers, HttpClientRequest } from "effect/unstable/http"
+import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 import {
   ImageModel,
   GeneratedImage,
   ImageResponse,
-  type ImageRequest,
-  type ImageModelDefaults,
+  type ImageInput,
+  type ImageRequestFor,
   type ImageRoute,
 } from "../image"
 import { Auth, type Definition as AuthDefinition } from "../route/auth"
-import { InvalidProviderOutputReason, LLMError, Usage, mergeHttpOptions, mergeJsonRecords } from "../schema"
+import {
+  InvalidProviderOutputReason,
+  LLMError,
+  Usage,
+  mergeHttpOptions,
+  mergeJsonRecords,
+  type HttpOptions,
+} from "../schema"
 import { ProviderShared } from "./shared"
+import { ImageInputs } from "./utils/image-input"
 import { OpenAIImage } from "./utils/openai-image"
 
 const ADAPTER = "openai-images"
 export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
 export const PATH = "/images/generations"
+export const EDIT_PATH = "/images/edits"
+
+export type OpenAIImageString<Known extends string> = Known | (string & {})
 
-export interface OpenAIImageOptions {
-  readonly quality?: "auto" | "low" | "medium" | "high"
-  readonly background?: "auto" | "opaque" | "transparent"
-  readonly moderation?: "auto" | "low"
-  readonly outputFormat?: "png" | "jpeg" | "webp"
+export type OpenAIImageOptions = {
+  readonly mask?: ImageInput
+  readonly n?: number
+  readonly size?: OpenAIImageString<
+    "auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
+  >
+  readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
+  readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
+  readonly moderation?: OpenAIImageString<"auto" | "low">
+  readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
   readonly outputCompression?: number
-}
+} & Record<string, unknown>
 
-const OpenAIImageBody = Schema.Struct({
-  model: Schema.String,
-  prompt: Schema.String,
-  n: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
-  size: Schema.optional(Schema.String),
-  quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
-  background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
-  moderation: Schema.optional(Schema.Literals(["auto", "low"])),
-  output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
-  output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
-})
-export type OpenAIImageBody = Schema.Schema.Type<typeof OpenAIImageBody>
+export type OpenAIImageBody = Record<string, unknown> & {
+  readonly model: string
+  readonly prompt: string
+}
 
 const OpenAIImageResponse = Schema.Struct({
   data: Schema.Array(
@@ -63,26 +71,16 @@ export interface ModelInput {
   readonly auth: AuthDefinition
   readonly baseURL?: string
   readonly headers?: Record<string, string>
-  readonly defaults?: ImageModelDefaults
+  readonly http?: HttpOptions
 }
 
-const providerOptions = (request: ImageRequest): OpenAIImageOptions => ({
-  ...request.model.defaults?.providerOptions?.openai,
-  ...request.providerOptions?.openai,
-})
-
-const body = (request: ImageRequest): OpenAIImageBody => {
-  const options = providerOptions(request)
+const nativeOptions = (options: OpenAIImageOptions | undefined) => {
+  if (!options) return undefined
+  const { mask: _, outputFormat, outputCompression, ...native } = options
   return {
-    model: request.model.id,
-    prompt: request.prompt,
-    n: request.count,
-    size: request.size === undefined ? undefined : `${request.size.width}x${request.size.height}`,
-    quality: options.quality,
-    background: options.background,
-    moderation: options.moderation,
-    output_format: options.outputFormat,
-    output_compression: options.outputCompression,
+    output_format: outputFormat,
+    output_compression: outputCompression,
+    ...native,
   }
 }
 
@@ -100,45 +98,93 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
   return next.toString()
 }
 
-const PROTOCOL_BODY_FIELDS = new Set([
-  "model",
-  "prompt",
-  "n",
-  "size",
-  "quality",
-  "background",
-  "moderation",
-  "output_format",
-  "output_compression",
-])
-
-const bodyWithOverlay = Effect.fn("OpenAIImages.bodyWithOverlay")(function* (
-  imageBody: OpenAIImageBody,
-  overlay: Record<string, unknown> | undefined,
-) {
-  if (!overlay) return imageBody
-  const reserved = Object.keys(overlay).filter((key) => PROTOCOL_BODY_FIELDS.has(key))
-  if (reserved.length > 0)
-    return yield* ProviderShared.invalidRequest(
-      `http.body cannot overlay protocol-owned field(s): ${reserved.join(", ")}`,
-    )
-  return mergeJsonRecords(imageBody, overlay) ?? imageBody
-})
-
 export const model = (input: ModelInput) => {
-  const route: ImageRoute = {
+  const route: ImageRoute<OpenAIImageOptions> = {
     id: ADAPTER,
-    generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequest, execute) {
-      if (request.aspectRatio !== undefined)
-        return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common aspectRatio option")
-      if (request.seed !== undefined)
-        return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common seed option")
+    generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
+      const mask = request.options?.mask
+      if (mask !== undefined && (request.images?.length ?? 0) === 0)
+        return yield* ImageInputs.invalid(ADAPTER, "An OpenAI image mask requires at least one input image")
+      const http = mergeHttpOptions(request.model.http, request.http)
+      const sourceImages = request.images ?? []
+      const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
+        if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
+        if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
+        return Effect.succeed(undefined)
+      })
+      const multipartMask =
+        mask === undefined
+          ? undefined
+          : mask.type === "bytes"
+            ? { data: mask.data, mediaType: mask.mediaType }
+            : mask.type === "url"
+              ? yield* ImageInputs.decodeDataUrl(mask.url, ADAPTER)
+              : undefined
+      const useMultipart =
+        sourceImages.length > 0 &&
+        multipartImages.every((image) => image !== undefined) &&
+        (mask === undefined || multipartMask !== undefined)
+      const path = sourceImages.length === 0 ? PATH : EDIT_PATH
+      const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${path}`, http?.query)
 
-      const requestBody = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))(body(request))
-      const http = mergeHttpOptions(request.model.defaults?.http, request.http)
-      const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body)
-      const text = ProviderShared.encodeJson(overlaidBody)
-      const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
+      if (useMultipart) {
+        const form = new FormData()
+        form.append("model", request.model.id)
+        form.append("prompt", request.prompt)
+        Object.entries(mergeJsonRecords(nativeOptions(request.options), http?.body) ?? {}).forEach(([key, value]) => {
+          if (["model", "prompt", "image", "image[]", "images", "mask"].includes(key)) return
+          form.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value))
+        })
+        multipartImages.forEach((image, index) => {
+          if (image === undefined) return
+          form.append("image[]", imageBlob(image.data, image.mediaType), `image-${index}`)
+        })
+        if (multipartMask !== undefined)
+          form.append("mask", imageBlob(multipartMask.data, multipartMask.mediaType), "mask")
+        const headers = yield* Auth.toEffect(input.auth)({
+          request,
+          method: "POST",
+          url,
+          body: "[multipart/form-data]",
+          headers: Headers.remove(Headers.fromInput({ ...input.headers, ...http?.headers }), "content-type"),
+        })
+        const response = yield* execute(
+          HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyFormData(form)),
+        )
+        return yield* parseResponse(response, request.options, http?.body)
+      }
+
+      const references = sourceImages.map((image) => {
+        if (image.type === "bytes") return { image_url: ImageInputs.dataUrl(image) }
+        if (image.type === "url") return { image_url: image.url }
+        if (image.type === "file-id") return { file_id: image.id }
+        return undefined
+      })
+      if (references.some((image) => image === undefined))
+        return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
+      const maskReference =
+        mask === undefined
+          ? undefined
+          : mask.type === "bytes"
+            ? { image_url: ImageInputs.dataUrl(mask) }
+            : mask.type === "url"
+              ? { image_url: mask.url }
+              : mask.type === "file-id"
+                ? { file_id: mask.id }
+                : undefined
+      if (mask !== undefined && maskReference === undefined)
+        return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
+      const requestBody = mergeJsonRecords(
+        {
+          model: request.model.id,
+          prompt: request.prompt,
+          images: references.length === 0 ? undefined : references,
+          mask: maskReference,
+        },
+        nativeOptions(request.options),
+        http?.body,
+      ) as OpenAIImageBody
+      const text = ProviderShared.encodeJson(requestBody)
       const headers = yield* Auth.toEffect(input.auth)({
         request,
         method: "POST",
@@ -152,55 +198,71 @@ export const model = (input: ModelInput) => {
           HttpClientRequest.bodyText(text, "application/json"),
         ),
       )
-      const payload = yield* response.json.pipe(
-        Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
-      )
-      const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
-        Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
-      )
-      const format = decoded.output_format ?? providerOptions(request).outputFormat ?? "png"
-      const images = yield* Effect.forEach(decoded.data, (item, index) => {
-        if (item.b64_json)
-          return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
-            Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
-            Effect.map(
-              (data) =>
-                new GeneratedImage({
-                  mediaType: `image/${format}`,
-                  data,
-                  providerMetadata:
-                    item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
-                }),
-            ),
-          )
-        if (item.url)
-          return Effect.succeed(
+      return yield* parseResponse(response, request.options, http?.body)
+    }),
+  }
+  return ImageModel.make<OpenAIImageOptions>({ id: input.id, provider: "openai", route, http: input.http })
+}
+
+const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
+  response: HttpClientResponse.HttpClientResponse,
+  options: OpenAIImageOptions | undefined,
+  overlay: Record<string, unknown> | undefined,
+) {
+  const payload = yield* response.json.pipe(
+    Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
+  )
+  const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
+    Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
+  )
+  const requestBody = mergeJsonRecords(nativeOptions(options), overlay)
+  const format =
+    decoded.output_format ?? (typeof requestBody?.output_format === "string" ? requestBody.output_format : "png")
+  const images = yield* Effect.forEach(decoded.data, (item, index) => {
+    if (item.b64_json)
+      return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
+        Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
+        Effect.map(
+          (data) =>
             new GeneratedImage({
               mediaType: `image/${format}`,
-              data: item.url,
+              data,
               providerMetadata:
                 item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
             }),
-          )
-        return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
-      })
-      if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
-      return new ImageResponse({
-        images,
-        usage:
-          decoded.usage === undefined
-            ? undefined
-            : new Usage({
-                inputTokens: decoded.usage.input_tokens,
-                outputTokens: decoded.usage.output_tokens,
-                totalTokens: decoded.usage.total_tokens,
-                providerMetadata: { openai: decoded.usage },
-              }),
-        providerMetadata: { openai: { outputFormat: format } },
-      })
-    }),
-  }
-  return ImageModel.make({ id: input.id, provider: "openai", route, defaults: input.defaults })
+        ),
+      )
+    if (item.url)
+      return Effect.succeed(
+        new GeneratedImage({
+          mediaType: `image/${format}`,
+          data: item.url,
+          providerMetadata:
+            item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
+        }),
+      )
+    return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
+  })
+  if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
+  return new ImageResponse({
+    images,
+    usage:
+      decoded.usage === undefined
+        ? undefined
+        : new Usage({
+            inputTokens: decoded.usage.input_tokens,
+            outputTokens: decoded.usage.output_tokens,
+            totalTokens: decoded.usage.total_tokens,
+            providerMetadata: { openai: decoded.usage },
+          }),
+    providerMetadata: { openai: { outputFormat: format } },
+  })
+})
+
+const imageBlob = (data: Uint8Array, mediaType: string) => {
+  const buffer = new ArrayBuffer(data.byteLength)
+  new Uint8Array(buffer).set(data)
+  return new Blob([buffer], { type: mediaType })
 }
 
 export const OpenAIImages = {

+ 34 - 0
packages/ai/src/protocols/utils/image-input.ts

@@ -0,0 +1,34 @@
+import { Effect, Encoding } from "effect"
+import type { ImageInput } from "../../image"
+import { InvalidRequestReason, LLMError } from "../../schema"
+
+const invalid = (module: string, message: string) =>
+  new LLMError({
+    module,
+    method: "generate",
+    reason: new InvalidRequestReason({ message }),
+  })
+
+export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>) =>
+  `data:${input.mediaType};base64,${Encoding.encodeBase64(input.data)}`
+
+export const decodeDataUrl = (
+  url: string,
+  module: string,
+): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, LLMError> => {
+  if (!url.startsWith("data:")) return Effect.succeed(undefined)
+  const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
+  if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
+  return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
+    Effect.mapError(() => invalid(module, "Image data URL contains invalid base64 data")),
+    Effect.map((data) => ({ mediaType: match[1], data })),
+  )
+}
+
+export const invalidImageInput = invalid
+
+export const ImageInputs = {
+  dataUrl,
+  decodeDataUrl,
+  invalid: invalidImageInput,
+} as const

+ 1 - 1
packages/ai/src/protocols/utils/lifecycle.ts

@@ -44,7 +44,7 @@ export const reasoningDelta = (
   providerMetadata?: ProviderMetadata,
 ): State => {
   const started = reasoningStart(state, events, id, providerMetadata)
-  events.push(LLMEvent.reasoningDelta({ id, text }))
+  events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
   return started
 }
 

+ 2 - 2
packages/ai/src/protocols/utils/tool-stream.ts

@@ -140,8 +140,8 @@ export const appendOrStart = <K extends StreamKey>(
   missingToolMessage: string,
 ): AppendOutcome<K> | LLMError => {
   const current = tools[key]
-  const id = delta.id ?? current?.id
-  const name = delta.name ?? current?.name
+  const id = current?.id ?? delta.id
+  const name = current?.name ?? delta.name
   if (!id || !name) return eventError(route, missingToolMessage)
 
   const tool = {

+ 202 - 0
packages/ai/src/protocols/xai-images.ts

@@ -0,0 +1,202 @@
+import { Effect, Encoding, Schema } from "effect"
+import { Headers, HttpClientRequest } from "effect/unstable/http"
+import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
+import { Auth, type Definition as AuthDefinition } from "../route/auth"
+import {
+  InvalidProviderOutputReason,
+  LLMError,
+  Usage,
+  mergeHttpOptions,
+  mergeJsonRecords,
+  type HttpOptions,
+} from "../schema"
+import { ProviderShared, optionalNull } from "./shared"
+import { ImageInputs } from "./utils/image-input"
+
+const ADAPTER = "xai-images"
+export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
+export const PATH = "/images/generations"
+export const EDIT_PATH = "/images/edits"
+
+export type XAIImageString<Known extends string> = Known | (string & {})
+
+export type XAIImageOptions = {
+  readonly n?: number
+  readonly aspectRatio?: XAIImageString<
+    | "1:1"
+    | "3:4"
+    | "4:3"
+    | "9:16"
+    | "16:9"
+    | "2:3"
+    | "3:2"
+    | "9:19.5"
+    | "19.5:9"
+    | "9:20"
+    | "20:9"
+    | "1:2"
+    | "2:1"
+    | "auto"
+  >
+  readonly aspect_ratio?: XAIImageString<
+    | "1:1"
+    | "3:4"
+    | "4:3"
+    | "9:16"
+    | "16:9"
+    | "2:3"
+    | "3:2"
+    | "9:19.5"
+    | "19.5:9"
+    | "9:20"
+    | "20:9"
+    | "1:2"
+    | "2:1"
+    | "auto"
+  >
+  readonly resolution?: XAIImageString<"1k" | "2k">
+  readonly responseFormat?: XAIImageString<"url" | "b64_json">
+  readonly response_format?: XAIImageString<"url" | "b64_json">
+} & Record<string, unknown>
+
+type XAIImageBody = Record<string, unknown> & {
+  readonly model: string
+  readonly prompt: string
+}
+
+const XAIImageResponse = Schema.Struct({
+  data: Schema.Array(
+    Schema.Struct({
+      b64_json: optionalNull(Schema.String),
+      url: optionalNull(Schema.String),
+      revised_prompt: optionalNull(Schema.String),
+      mime_type: optionalNull(Schema.String),
+    }),
+  ),
+  usage: Schema.optional(Schema.Unknown),
+})
+
+export interface ModelInput {
+  readonly id: string
+  readonly auth: AuthDefinition
+  readonly baseURL?: string
+  readonly headers?: Record<string, string>
+  readonly http?: HttpOptions
+}
+
+const nativeOptions = (options: XAIImageOptions | undefined) => {
+  if (!options) return undefined
+  const { aspectRatio, responseFormat, ...native } = options
+  return {
+    aspect_ratio: aspectRatio,
+    response_format: responseFormat,
+    ...native,
+  }
+}
+
+const invalidOutput = (message: string) =>
+  new LLMError({
+    module: ADAPTER,
+    method: "generate",
+    reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
+  })
+
+const applyQuery = (url: string, query: Record<string, string> | undefined) => {
+  if (!query) return url
+  const next = new URL(url)
+  Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
+  return next.toString()
+}
+
+export const model = (input: ModelInput) => {
+  const route: ImageRoute<XAIImageOptions> = {
+    id: ADAPTER,
+    generate: Effect.fn("XAIImages.generate")(function* (request: ImageRequestFor<XAIImageOptions>, execute) {
+      const http = mergeHttpOptions(request.model.http, request.http)
+      const imageReferences = (request.images ?? []).map((image) => {
+        if (image.type === "bytes") return { url: ImageInputs.dataUrl(image), type: "image_url" as const }
+        if (image.type === "url") return { url: image.url, type: "image_url" as const }
+        if (image.type === "file-id") return { file_id: image.id }
+        return undefined
+      })
+      if (imageReferences.some((image) => image === undefined))
+        return yield* ImageInputs.invalid(ADAPTER, "xAI Images accepts image URLs, data URLs, bytes, and file IDs")
+      const requestBody = mergeJsonRecords(
+        {
+          model: request.model.id,
+          prompt: request.prompt,
+          image: imageReferences.length === 1 ? imageReferences[0] : undefined,
+          images: imageReferences.length > 1 ? imageReferences : undefined,
+        },
+        nativeOptions(request.options),
+        http?.body,
+      ) as XAIImageBody
+      const text = ProviderShared.encodeJson(requestBody)
+      const url = applyQuery(
+        `${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${imageReferences.length === 0 ? PATH : EDIT_PATH}`,
+        http?.query,
+      )
+      const headers = yield* Auth.toEffect(input.auth)({
+        request,
+        method: "POST",
+        url,
+        body: text,
+        headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
+      })
+      const response = yield* execute(
+        HttpClientRequest.post(url).pipe(
+          HttpClientRequest.setHeaders(headers),
+          HttpClientRequest.bodyText(text, "application/json"),
+        ),
+      )
+      const payload = yield* response.json.pipe(
+        Effect.mapError(() => invalidOutput("Failed to read the xAI Images response")),
+      )
+      const decoded = yield* Schema.decodeUnknownEffect(XAIImageResponse)(payload).pipe(
+        Effect.mapError(() => invalidOutput("xAI Images returned an invalid response")),
+      )
+      const images = yield* Effect.forEach(decoded.data, (item, index) => {
+        const mediaType = item.mime_type ?? "application/octet-stream"
+        if (item.b64_json)
+          return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
+            Effect.mapError(() => invalidOutput(`xAI Images result ${index} contains invalid base64 data`)),
+            Effect.map(
+              (data) =>
+                new GeneratedImage({
+                  mediaType,
+                  data,
+                  providerMetadata:
+                    item.revised_prompt === undefined || item.revised_prompt === null
+                      ? undefined
+                      : { xai: { revisedPrompt: item.revised_prompt } },
+                }),
+            ),
+          )
+        if (item.url)
+          return Effect.succeed(
+            new GeneratedImage({
+              mediaType,
+              data: item.url,
+              providerMetadata:
+                item.revised_prompt === undefined || item.revised_prompt === null
+                  ? undefined
+                  : { xai: { revisedPrompt: item.revised_prompt } },
+            }),
+          )
+        return Effect.fail(invalidOutput(`xAI Images result ${index} has neither image data nor a URL`))
+      })
+      if (images.length === 0) return yield* invalidOutput("xAI Images returned no images")
+      const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
+      return new ImageResponse({
+        images,
+        usage: usage === undefined ? undefined : new Usage({ providerMetadata: { xai: usage } }),
+        providerMetadata: usage === undefined ? undefined : { xai: { usage } },
+      })
+    }),
+  }
+  return ImageModel.make<XAIImageOptions>({ id: input.id, provider: "xai", route, http: input.http })
+}
+
+export const XAIImages = {
+  model,
+} as const

+ 132 - 0
packages/ai/src/protocols/zai-images.ts

@@ -0,0 +1,132 @@
+import { Effect, Schema } from "effect"
+import { Headers, HttpClientRequest } from "effect/unstable/http"
+import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
+import { Auth, type Definition as AuthDefinition } from "../route/auth"
+import { InvalidProviderOutputReason, LLMError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema"
+import { ProviderShared } from "./shared"
+import { ImageInputs } from "./utils/image-input"
+
+const ADAPTER = "zai-images"
+export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
+export const PATH = "/images/generations"
+
+export type ZAIImageString<Known extends string> = Known | (string & {})
+
+export type ZAIImageOptions = {
+  readonly size?: ZAIImageString<
+    "1024x1024" | "768x1344" | "864x1152" | "1344x768" | "1152x864" | "1440x720" | "720x1440"
+  >
+  readonly quality?: ZAIImageString<"hd" | "standard">
+  readonly userID?: string
+} & Record<string, unknown>
+
+type ZAIImageBody = Record<string, unknown> & {
+  readonly model: string
+  readonly prompt: string
+}
+
+const ZAIImageResponse = Schema.Struct({
+  created: Schema.optional(Schema.Int),
+  id: Schema.optional(Schema.String),
+  request_id: Schema.optional(Schema.String),
+  data: Schema.Array(Schema.Struct({ url: Schema.String })),
+  content_filter: Schema.optional(
+    Schema.Array(
+      Schema.Struct({
+        role: Schema.optional(Schema.String),
+        level: Schema.optional(Schema.Number),
+      }),
+    ),
+  ),
+})
+
+export interface ModelInput {
+  readonly id: string
+  readonly auth: AuthDefinition
+  readonly baseURL?: string
+  readonly headers?: Record<string, string>
+  readonly http?: HttpOptions
+}
+
+const nativeOptions = (options: ZAIImageOptions | undefined) => {
+  if (!options) return undefined
+  const { userID, ...native } = options
+  return {
+    user_id: userID,
+    ...native,
+  }
+}
+
+const invalidOutput = (message: string) =>
+  new LLMError({
+    module: ADAPTER,
+    method: "generate",
+    reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
+  })
+
+const applyQuery = (url: string, query: Record<string, string> | undefined) => {
+  if (!query) return url
+  const next = new URL(url)
+  Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
+  return next.toString()
+}
+
+export const model = (input: ModelInput) => {
+  const route: ImageRoute<ZAIImageOptions> = {
+    id: ADAPTER,
+    generate: Effect.fn("ZAIImages.generate")(function* (request: ImageRequestFor<ZAIImageOptions>, execute) {
+      if ((request.images?.length ?? 0) > 0)
+        return yield* ImageInputs.invalid(ADAPTER, "Z.ai hosted image generation does not support image inputs")
+      const http = mergeHttpOptions(request.model.http, request.http)
+      const requestBody = mergeJsonRecords(
+        { model: request.model.id, prompt: request.prompt },
+        nativeOptions(request.options),
+        http?.body,
+      ) as ZAIImageBody
+      const text = ProviderShared.encodeJson(requestBody)
+      const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
+      const headers = yield* Auth.toEffect(input.auth)({
+        request,
+        method: "POST",
+        url,
+        body: text,
+        headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
+      })
+      const response = yield* execute(
+        HttpClientRequest.post(url).pipe(
+          HttpClientRequest.setHeaders(headers),
+          HttpClientRequest.bodyText(text, "application/json"),
+        ),
+      )
+      const payload = yield* response.json.pipe(
+        Effect.mapError(() => invalidOutput("Failed to read the Z.ai Images response")),
+      )
+      const decoded = yield* Schema.decodeUnknownEffect(ZAIImageResponse)(payload).pipe(
+        Effect.mapError(() => invalidOutput("Z.ai Images returned an invalid response")),
+      )
+      if (decoded.data.length === 0) return yield* invalidOutput("Z.ai Images returned no images")
+      return new ImageResponse({
+        images: decoded.data.map(
+          (item) =>
+            new GeneratedImage({
+              mediaType: "application/octet-stream",
+              data: item.url,
+            }),
+        ),
+        providerMetadata: {
+          zai: {
+            created: decoded.created,
+            id: decoded.id,
+            requestID: decoded.request_id,
+            contentFilter: decoded.content_filter,
+          },
+        },
+      })
+    }),
+  }
+  return ImageModel.make<ZAIImageOptions>({ id: input.id, provider: "zai", route, http: input.http })
+}
+
+export const ZAIImages = {
+  model,
+} as const

+ 11 - 1
packages/ai/src/provider-error.ts

@@ -16,13 +16,17 @@ import {
 
 const patterns = [
   /prompt is too long/i,
+  /request_too_large/i,
   /input is too long for requested model/i,
   /exceeds the context window/i,
+  /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
   /input token count.*exceeds the maximum/i,
   /tokens in request more than max tokens allowed/i,
   /maximum prompt length is \d+/i,
   /reduce the length of the messages/i,
   /maximum context length is \d+ tokens/i,
+  /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
+  /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
   /exceeds the limit of \d+/i,
   /exceeds the available context size/i,
   /greater than the context length/i,
@@ -34,11 +38,17 @@ const patterns = [
   /input length.*exceeds.*context length/i,
   /prompt too long; exceeded (?:max )?context length/i,
   /too large for model with \d+ maximum context length/i,
+  /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
   /model_context_window_exceeded/i,
+  /too many tokens/i,
+  /token limit exceeded/i,
 ]
 
+const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
+
 export const isContextOverflow = (message: string) =>
-  patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
+  !exclusions.some((pattern) => pattern.test(message)) &&
+  (patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
 
 export const isContextOverflowFailure = (failure: unknown) =>
   failure instanceof LLMError

+ 20 - 3
packages/ai/src/providers/google.ts

@@ -2,14 +2,20 @@ import type { RouteDefaultsInput } from "../route/client"
 import { Auth } from "../route/auth"
 import type { ProviderAuthOption } from "../route/auth-options"
 import type { ProviderPackage } from "../provider-package"
-import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
-import * as Gemini from "../protocols/gemini"
+import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID, type ProviderOptions } from "../schema"
+import { Gemini } from "../protocols/gemini"
+import { GoogleImages } from "../protocols/google-images"
+
+export type { GoogleImageOptions } from "../protocols/google-images"
 
 export const id = ProviderID.make("google")
 
 export const routes = [Gemini.route]
 
-export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
+export type Config = RouteDefaultsInput &
+  ProviderAuthOption<"optional"> & {
+    readonly baseURL?: string
+  }
 
 export interface Settings extends ProviderPackage.Settings {
   readonly apiKey?: string
@@ -31,9 +37,18 @@ const configuredRoute = (input: Config) => {
 
 export const configure = (input: Config = {}) => {
   const route = configuredRoute(input)
+  const image = (modelID: string | ModelID) =>
+    GoogleImages.model({
+      id: modelID,
+      auth: auth(input),
+      baseURL: input.baseURL,
+      headers: input.headers,
+      http: mergeHttpOptions(input.http === undefined ? undefined : HttpOptions.make(input.http)),
+    })
   return {
     id,
     model: (modelID: string | ModelID) => route.model({ id: modelID }),
+    image,
     configure,
   }
 }
@@ -48,3 +63,5 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
     limits: settings.limits,
     providerOptions: settings.providerOptions,
   }).model(modelID)
+
+export const image = provider.image

+ 1 - 0
packages/ai/src/providers/index.ts

@@ -15,3 +15,4 @@ export * as OpenAICompatible from "./openai-compatible"
 export * as OpenAICompatibleResponses from "./openai-compatible-responses"
 export * as OpenRouter from "./openrouter"
 export * as XAI from "./xai"
+export * as ZAI from "./zai"

+ 14 - 21
packages/ai/src/providers/openai.ts

@@ -5,7 +5,7 @@ import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID
 import * as OpenAIChat from "../protocols/openai-chat"
 import * as OpenAIResponses from "../protocols/openai-responses"
 import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
-import { OpenAIImages, type OpenAIImageOptions } from "../protocols/openai-images"
+import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images"
 
 export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
 export type { OpenAIImageOptions } from "../protocols/openai-images"
@@ -22,22 +22,19 @@ export type Config = RouteDefaultsInput &
     readonly baseURL?: string
     readonly queryParams?: Record<string, string>
     readonly providerOptions?: OpenAIProviderOptionsInput
-    readonly image?: ImageConfig
   }
 
-export interface ImageConfig {
-  readonly providerOptions?: OpenAIImageOptions
-}
-
 export interface ImageGenerationOptions {
-  readonly action?: "auto" | "generate" | "edit"
-  readonly background?: "auto" | "opaque" | "transparent"
-  readonly inputFidelity?: "low" | "high"
+  readonly action?: OpenAIImageString<"auto" | "generate" | "edit">
+  readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
+  readonly inputFidelity?: OpenAIImageString<"low" | "high">
   readonly outputCompression?: number
-  readonly outputFormat?: "png" | "jpeg" | "webp"
+  readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
   readonly partialImages?: number
-  readonly quality?: "auto" | "low" | "medium" | "high"
-  readonly size?: string
+  readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
+  readonly size?: OpenAIImageString<
+    "auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
+  >
 }
 
 export const imageGeneration = (options: ImageGenerationOptions = {}) =>
@@ -73,7 +70,7 @@ export interface Settings extends ProviderPackage.Settings {
 const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
 
 const defaults = (input: Config) => {
-  const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, image: _image, ...rest } = input
+  const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, ...rest } = input
   return rest
 }
 
@@ -99,14 +96,10 @@ export const configure = (input: Config = {}) => {
       auth: auth(input),
       baseURL: input.baseURL,
       headers: input.headers,
-      defaults: {
-        providerOptions:
-          input.image?.providerOptions === undefined ? undefined : { openai: { ...input.image.providerOptions } },
-        http: mergeHttpOptions(
-          input.http === undefined ? undefined : HttpOptions.make(input.http),
-          input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
-        ),
-      },
+      http: mergeHttpOptions(
+        input.http === undefined ? undefined : HttpOptions.make(input.http),
+        input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
+      ),
     })
 
   return {

+ 25 - 7
packages/ai/src/providers/openrouter.ts

@@ -41,13 +41,31 @@ export const protocol = Protocol.make({
     schema: OpenRouterBody,
     from: (request) =>
       OpenAIChat.protocol.body.from(request).pipe(
-        Effect.map(
-          (body) =>
-            ({
-              ...body,
-              ...bodyOptions(request.providerOptions?.openrouter),
-            }) as OpenRouterBody,
-        ),
+        Effect.map((body) => {
+          const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
+          let assistantIndex = 0
+          const messages = body.messages.map((message) => {
+            if (message.role !== "assistant") return message
+            const source = sourceAssistants[assistantIndex++]
+            const reasoning = source?.content
+              .filter((part) => part.type === "reasoning")
+              .map((part) => part.text)
+              .join("")
+            const reasoningDetails = Array.isArray(message.reasoning_details) ? message.reasoning_details : undefined
+            return {
+              ...message,
+              reasoning_content: undefined,
+              reasoning_text: undefined,
+              reasoning: reasoning && reasoningDetails && reasoningDetails.length > 0 ? reasoning : undefined,
+              reasoning_details: reasoningDetails,
+            }
+          })
+          return {
+            ...body,
+            messages,
+            ...bodyOptions(request.providerOptions?.openrouter),
+          } as OpenRouterBody
+        }),
       ),
   },
   stream: OpenAIChat.protocol.stream,

+ 14 - 1
packages/ai/src/providers/xai.ts

@@ -1,9 +1,10 @@
 import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
 import type { RouteDefaultsInput } from "../route/client"
-import { ProviderID, type ModelID } from "../schema"
+import { HttpOptions, ProviderID, type ModelID } from "../schema"
 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"
 
 export const id = ProviderID.make("xai")
 
@@ -12,6 +13,8 @@ export type ModelOptions = RouteDefaultsInput &
     readonly baseURL?: string
   }
 
+export type { XAIImageOptions } from "../protocols/xai-images"
+
 export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
 
 const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
@@ -41,11 +44,20 @@ export const configure = (input: ModelOptions = {}) => {
   const chatRoute = configuredChatRoute(input)
   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,
+      auth: auth(input),
+      baseURL: input.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL,
+      headers: input.headers,
+      http: input.http === undefined ? undefined : HttpOptions.make(input.http),
+    })
   return {
     id,
     model: responses,
     responses,
     chat,
+    image,
     configure,
   }
 }
@@ -54,3 +66,4 @@ export const provider = configure()
 export const model = provider.model
 export const responses = provider.responses
 export const chat = provider.chat
+export const image = provider.image

+ 35 - 0
packages/ai/src/providers/zai.ts

@@ -0,0 +1,35 @@
+import { ZAIImages } from "../protocols/zai-images"
+import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
+import { HttpOptions, ProviderID, type ModelID } from "../schema"
+
+export const id = ProviderID.make("zai")
+
+export type Config = ProviderAuthOption<"optional"> & {
+  readonly baseURL?: string
+  readonly headers?: Record<string, string>
+  readonly http?: HttpOptions.Input
+}
+
+export type { ZAIImageOptions } from "../protocols/zai-images"
+
+const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "ZAI_API_KEY")
+
+export const configure = (input: Config = {}) => {
+  const image = (modelID: string | ModelID) =>
+    ZAIImages.model({
+      id: modelID,
+      auth: auth(input),
+      baseURL: input.baseURL,
+      headers: input.headers,
+      http: input.http === undefined ? undefined : HttpOptions.make(input.http),
+    })
+
+  return {
+    id,
+    image,
+    configure,
+  }
+}
+
+export const provider = configure()
+export const image = provider.image

+ 18 - 21
packages/ai/test/auth-options.types.ts

@@ -1,7 +1,6 @@
 import { Config } from "effect"
-import type { Auth } from "../src/route/auth"
+import { Auth } from "../src/route"
 import type { ModelFactory } from "../src/route/auth-options"
-import { Auth as RuntimeAuth } from "../src/route/auth"
 import * as OpenAIChat from "../src/protocols/openai-chat"
 import * as AmazonBedrock from "../src/providers/amazon-bedrock"
 import * as Anthropic from "../src/providers/anthropic"
@@ -28,7 +27,7 @@ type Model = {
   readonly id: string
 }
 
-declare const auth: Auth
+declare const auth: Auth.Definition
 declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
 declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
 const configApiKey = Config.redacted("OPENAI_API_KEY")
@@ -76,9 +75,9 @@ OpenAI.responses("gpt-4.1-mini")
 OpenAI.configure({}).responses("gpt-4.1-mini")
 OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini")
 OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini")
-OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).responses("gpt-4.1-mini")
+OpenAI.configure({ auth: Auth.bearer("oauth-token") }).responses("gpt-4.1-mini")
 OpenAI.configure({
-  auth: RuntimeAuth.headers({ authorization: "Bearer gateway" }),
+  auth: Auth.headers({ authorization: "Bearer gateway" }),
   baseURL: "https://gateway.example.com/v1",
 }).responses("gpt-4.1-mini")
 OpenAI.configure({
@@ -102,40 +101,40 @@ OpenAI.configure({ generation: { maxTokens: "many" } })
 OpenAI.configure({ providerOptions: { openai: { store: "false" } } })
 
 // @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
-OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
+OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
 
 OpenAI.chat("gpt-4.1-mini")
 OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini")
 OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini")
-OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).chat("gpt-4.1-mini")
+OpenAI.configure({ auth: Auth.bearer("oauth-token") }).chat("gpt-4.1-mini")
 
 // @ts-expect-error OpenAI chat selectors only accept model ids.
 OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini", {})
 
 // @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth.
-OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
+OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
 
 // @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`.
 Azure.configure()
 Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment")
 Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).responses("deployment")
-Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).responses("deployment")
+Azure.configure({ auth: Auth.header("api-key", "azure-key"), resourceName: "resource" }).responses("deployment")
 
 // @ts-expect-error Azure model selectors only accept deployment ids.
 Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment", {})
 
 // @ts-expect-error auth is an override, so Azure rejects apiKey with auth.
-Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
+Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
 
 Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment")
 Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).chat("deployment")
-Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).chat("deployment")
+Azure.configure({ auth: Auth.header("api-key", "azure-key"), resourceName: "resource" }).chat("deployment")
 
 // @ts-expect-error Azure chat model selectors only accept deployment ids.
 Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment", {})
 
 // @ts-expect-error auth is an override, so Azure Chat rejects apiKey with auth.
-Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
+Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
 
 Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
 // @ts-expect-error Anthropic model selectors only accept model ids.
@@ -165,7 +164,7 @@ Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {})
 
 GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
 GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
-GoogleVertex.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
+GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
 // @ts-expect-error Vertex Gemini model selectors only accept model ids.
 GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
 // @ts-expect-error Vertex Gemini config accepts only one auth source.
@@ -174,7 +173,7 @@ GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", proj
 GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
 
 GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
-GoogleVertexChat.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
+GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
   "deepseek-ai/deepseek-v3.2-maas",
 )
 // @ts-expect-error Vertex Chat package settings do not accept API keys.
@@ -187,12 +186,12 @@ GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).
 GoogleVertexChat.configure({
   accessToken: "vertex-token",
   // @ts-expect-error Vertex Chat config accepts only one auth source.
-  auth: RuntimeAuth.bearer("vertex-token"),
+  auth: Auth.bearer("vertex-token"),
   project: "project",
 })
 
 GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model("xai/grok-4.20-reasoning")
-GoogleVertexResponses.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
+GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
   "xai/grok-4.20-reasoning",
 )
 // @ts-expect-error Vertex Responses package settings do not accept API keys.
@@ -205,16 +204,14 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
 GoogleVertexResponses.configure({
   accessToken: "vertex-token",
   // @ts-expect-error Vertex Responses config accepts only one auth source.
-  auth: RuntimeAuth.bearer("vertex-token"),
+  auth: Auth.bearer("vertex-token"),
   project: "project",
 })
 
 GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
 // @ts-expect-error Vertex Messages package settings do not accept API keys.
 GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
-GoogleVertexMessages.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
-  "claude-sonnet-4-6",
-)
+GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
 GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
   "claude-sonnet-4-6",
   // @ts-expect-error Vertex Messages model selectors only accept model ids.
@@ -223,7 +220,7 @@ GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project"
 GoogleVertexMessages.configure({
   accessToken: "vertex-token",
   // @ts-expect-error Vertex Messages config accepts only one auth source.
-  auth: RuntimeAuth.bearer("vertex-token"),
+  auth: Auth.bearer("vertex-token"),
   project: "project",
 })
 

+ 3 - 7
packages/ai/test/exports.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import { LLM, LLMClient, Provider } from "@opencode-ai/ai"
+import { ImageInput, LLM, LLMClient, Provider } from "@opencode-ai/ai"
 import { Route, Protocol } from "@opencode-ai/ai/route"
 import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
 import {
@@ -11,12 +11,7 @@ import {
   XAI,
 } from "@opencode-ai/ai/providers"
 import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
-import {
-  OpenAIChat,
-  OpenAICompatibleChat,
-  OpenAICompatibleResponses,
-  OpenAIResponses,
-} from "@opencode-ai/ai/protocols"
+import { OpenAIChat, OpenAICompatibleChat, OpenAICompatibleResponses, OpenAIResponses } from "@opencode-ai/ai/protocols"
 import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
 
 describe("public exports", () => {
@@ -24,6 +19,7 @@ describe("public exports", () => {
     expect(LLM.request).toBeFunction()
     expect(LLMClient.Service).toBeFunction()
     expect(LLMClient.layer).toBeDefined()
+    expect(ImageInput.bytes).toBeFunction()
     expect(Provider.make).toBeFunction()
     expect(ProviderSubpath.make).toBe(Provider.make)
   })

BIN
packages/ai/test/fixtures/images/edit-source.jpg


Разница между файлами не показана из-за своего большого размера
+ 23 - 0
packages/ai/test/fixtures/recordings/google-images/edits-an-image.json


Разница между файлами не показана из-за своего большого размера
+ 27 - 0
packages/ai/test/fixtures/recordings/google-images/generates-an-image.json


Разница между файлами не показана из-за своего большого размера
+ 16 - 0
packages/ai/test/fixtures/recordings/openai-images/edits-an-image.json


Разница между файлами не показана из-за своего большого размера
+ 32 - 0
packages/ai/test/fixtures/recordings/openrouter-reasoning-tool-loop.json


Разница между файлами не показана из-за своего большого размера
+ 32 - 0
packages/ai/test/fixtures/recordings/vercel-ai-gateway-reasoning-tool-loop.json


Разница между файлами не показана из-за своего большого размера
+ 23 - 0
packages/ai/test/fixtures/recordings/xai-images/edits-an-image.json


Разница между файлами не показана из-за своего большого размера
+ 27 - 0
packages/ai/test/fixtures/recordings/xai-images/generates-an-image.json


+ 28 - 0
packages/ai/test/fixtures/recordings/zai-images/generates-an-image.json

@@ -0,0 +1,28 @@
+{
+  "version": 1,
+  "metadata": {
+    "tags": ["prefix:zai-images", "provider:zai", "protocol:zai-images"],
+    "name": "zai-images/generates-an-image",
+    "recordedAt": "2026-07-19T16:03:55.761Z"
+  },
+  "interactions": [
+    {
+      "transport": "http",
+      "request": {
+        "method": "POST",
+        "url": "https://api.z.ai/api/paas/v4/images/generations",
+        "headers": {
+          "content-type": "application/json"
+        },
+        "body": "{\"model\":\"cogview-4-250304\",\"prompt\":\"A simple flat red circle centered on a plain white background.\",\"size\":\"1024x1024\",\"quality\":\"standard\",\"user_id\":\"opencode-image-test\"}"
+      },
+      "response": {
+        "status": 200,
+        "headers": {
+          "content-type": "application/json; charset=UTF-8"
+        },
+        "body": "{\"created\":1784477028,\"data\":[{\"url\":\"https://mfile.z.ai/1784477035500-43574eab2b6e402da9063d6ac22dfefb.png?ufileattname=202607200003482062c3bba9b04f7d_watermark.png\"}],\"id\":\"202607200003482062c3bba9b04f7d\",\"request_id\":\"202607200003482062c3bba9b04f7d\"}"
+      }
+    }
+  ]
+}

+ 499 - 16
packages/ai/test/image.test.ts

@@ -1,8 +1,8 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import { HttpClientRequest } from "effect/unstable/http"
-import { Image, ImageClient } from "../src"
-import { OpenAI } from "../src/providers"
+import { Image, ImageClient, ImageInput } from "../src"
+import { Google, OpenAI, XAI, ZAI } from "../src/providers"
 import { it } from "./lib/effect"
 import { dynamicResponse } from "./lib/http"
 
@@ -17,13 +17,20 @@ describe("Image", () => {
           http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
         }).image("gpt-image-2"),
         prompt: "A robot tending a rooftop garden",
-        count: 2,
-        size: { width: 1024, height: 1024 },
-        providerOptions: {
-          openai: { quality: "high", outputFormat: "webp" },
+        options: {
+          n: 2,
+          size: "2048x2048",
+          quality: "future-quality",
+          outputFormat: "jpeg",
+          output_format: "avif",
+          outputCompression: 30,
+          output_compression: 40,
+          background: "opaque",
+          native_default: true,
+          future_option: true,
         },
         http: {
-          body: { request_metadata: "value" },
+          body: { output_format: "webp", output_compression: 50, future_option: "http", request_metadata: "value" },
           headers: { "x-request": "yes" },
           query: { trace: "1" },
         },
@@ -49,9 +56,13 @@ describe("Image", () => {
                   model: "gpt-image-2",
                   prompt: "A robot tending a rooftop garden",
                   n: 2,
-                  size: "1024x1024",
-                  quality: "high",
+                  size: "2048x2048",
+                  quality: "future-quality",
+                  background: "opaque",
                   output_format: "webp",
+                  output_compression: 50,
+                  native_default: true,
+                  future_option: "http",
                   deployment: "test",
                   request_metadata: "value",
                 })
@@ -71,23 +82,495 @@ describe("Image", () => {
     ),
   )
 
-  it.effect("rejects invalid common and OpenAI image options locally", () =>
+  it.effect("preserves native snake_case and unknown request options", () =>
     Image.generate({
-      model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("gpt-image-2"),
+      model: OpenAI.configure({
+        apiKey: "test",
+        baseURL: "https://api.openai.test/v1",
+      }).image("future-image-model"),
+      prompt: "A lighthouse in fog",
+      options: {
+        outputFormat: "jpeg",
+        output_format: "avif",
+        outputCompression: 30,
+        output_compression: 40,
+        provider_future_option: { enabled: true },
+      },
+    }).pipe(
+      Effect.tap((response) =>
+        Effect.sync(() => {
+          expect(response.image?.mediaType).toBe("image/avif")
+        }),
+      ),
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse((input) => {
+              expect(JSON.parse(input.text)).toEqual({
+                model: "future-image-model",
+                prompt: "A lighthouse in fog",
+                output_format: "avif",
+                output_compression: 40,
+                provider_future_option: { enabled: true },
+              })
+              return Effect.succeed(
+                input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
+                  headers: { "content-type": "application/json" },
+                }),
+              )
+            }),
+          ),
+        ),
+      ),
+    ),
+  )
+
+  it.effect("routes OpenAI byte inputs and masks through multipart edits", () =>
+    Image.generate({
+      model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
+      prompt: "Combine these images",
+      images: [
+        ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
+        ImageInput.url("data:image/jpeg;base64,BAUG"),
+      ],
+      options: {
+        mask: ImageInput.bytes(Uint8Array.from([7, 8, 9]), "image/png"),
+        quality: "high",
+        future_option: true,
+      },
+      http: {
+        body: { quality: "low", model: "corrupt", prompt: "corrupt", image: "corrupt", "image[]": "corrupt" },
+        headers: { "content-type": "application/json" },
+      },
+    }).pipe(
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse((input) =>
+              Effect.gen(function* () {
+                const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
+                expect(request.url).toBe("https://api.openai.test/v1/images/edits")
+                expect(request.headers.get("content-type")).toStartWith("multipart/form-data; boundary=")
+                expect(input.text).toContain('name="model"\r\n\r\nfuture-model')
+                expect(input.text).toContain('name="prompt"\r\n\r\nCombine these images')
+                expect(input.text.match(/name="image\[\]"/g)).toHaveLength(2)
+                expect(input.text).toContain('name="mask"')
+                expect(input.text).toContain('name="quality"\r\n\r\nlow')
+                expect(input.text).not.toContain("corrupt")
+                return input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
+                  headers: { "content-type": "application/json" },
+                })
+              }),
+            ),
+          ),
+        ),
+      ),
+    ),
+  )
+
+  it.effect("routes OpenAI URL and file inputs through JSON edits", () =>
+    Image.generate({
+      model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
+      prompt: "Combine these images",
+      images: [ImageInput.url("https://example.test/source.png"), ImageInput.file("file_123")],
+      options: { mask: ImageInput.file("file_mask") },
+      http: { body: { future_option: true } },
+    }).pipe(
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse((input) => {
+              expect(JSON.parse(input.text)).toEqual({
+                model: "future-model",
+                prompt: "Combine these images",
+                images: [{ image_url: "https://example.test/source.png" }, { file_id: "file_123" }],
+                mask: { file_id: "file_mask" },
+                future_option: true,
+              })
+              return Effect.succeed(
+                input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
+                  headers: { "content-type": "application/json" },
+                }),
+              )
+            }),
+          ),
+        ),
+      ),
+    ),
+  )
+
+  it.effect("routes ordered xAI image inputs through JSON edits", () =>
+    Image.generate({
+      model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
+      prompt: "Combine these images",
+      images: [
+        ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
+        ImageInput.url("https://example.test/source.jpg"),
+        ImageInput.file("file_123"),
+      ],
+    }).pipe(
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse((input) => {
+              expect(JSON.parse(input.text)).toEqual({
+                model: "future-model",
+                prompt: "Combine these images",
+                images: [
+                  { url: "data:image/png;base64,AQID", type: "image_url" },
+                  { url: "https://example.test/source.jpg", type: "image_url" },
+                  { file_id: "file_123" },
+                ],
+              })
+              return Effect.succeed(
+                input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
+                  headers: { "content-type": "application/json" },
+                }),
+              )
+            }),
+          ),
+        ),
+      ),
+    ),
+  )
+
+  it.effect("uses xAI's singular image field for one input", () =>
+    Image.generate({
+      model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
+      prompt: "Edit this image",
+      images: [ImageInput.file("file_123")],
+    }).pipe(
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse((input) => {
+              expect(JSON.parse(input.text)).toEqual({
+                model: "future-model",
+                prompt: "Edit this image",
+                image: { file_id: "file_123" },
+              })
+              return Effect.succeed(
+                input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
+                  headers: { "content-type": "application/json" },
+                }),
+              )
+            }),
+          ),
+        ),
+      ),
+    ),
+  )
+
+  it.effect("lowers ordered Google image inputs into generateContent parts", () =>
+    Image.generate({
+      model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).image("future-model"),
+      prompt: "Combine these images",
+      images: [
+        ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
+        ImageInput.url("data:image/jpeg;base64,BAUG"),
+        ImageInput.fileUri("https://generativelanguage.googleapis.com/v1beta/files/123", "image/webp"),
+      ],
+    }).pipe(
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse((input) => {
+              expect(JSON.parse(input.text).contents[0].parts).toEqual([
+                { text: "Combine these images" },
+                { inlineData: { mimeType: "image/png", data: "AQID" } },
+                { inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
+                {
+                  fileData: {
+                    mimeType: "image/webp",
+                    fileUri: "https://generativelanguage.googleapis.com/v1beta/files/123",
+                  },
+                },
+              ])
+              return Effect.succeed(
+                input.respond(
+                  JSON.stringify({
+                    candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] } }],
+                  }),
+                  { headers: { "content-type": "application/json" } },
+                ),
+              )
+            }),
+          ),
+        ),
+      ),
+    ),
+  )
+
+  it.effect("rejects unsupported provider inputs before sending", () =>
+    Effect.gen(function* () {
+      const cases = [
+        Image.generate({
+          model: Google.configure({ apiKey: "test" }).image("model"),
+          prompt: "edit",
+          images: [ImageInput.url("https://example.test/image.png")],
+        }),
+        Image.generate({
+          model: ZAI.configure({ apiKey: "test" }).image("model"),
+          prompt: "edit",
+          images: [ImageInput.bytes(Uint8Array.from([1]), "image/png")],
+        }),
+      ]
+      yield* Effect.forEach(cases, (program) =>
+        program.pipe(
+          Effect.flip,
+          Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidRequest"))),
+        ),
+      )
+    }).pipe(
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(dynamicResponse(() => Effect.die("unsupported input reached the network"))),
+        ),
+      ),
+    ),
+  )
+
+  it.effect("generates images through the Google generateContent API", () =>
+    Effect.gen(function* () {
+      const response = yield* Image.generate({
+        model: Google.configure({
+          apiKey: "test",
+          baseURL: "https://generativelanguage.test/v1beta/",
+          headers: { "x-default": "yes" },
+          http: { body: { labels: { deployment: "test" } }, query: { api: "v1" } },
+        }).image("any-model-id"),
+        prompt: "A robot tending a rooftop garden",
+        options: {
+          aspectRatio: "16:9",
+          imageSize: "2K",
+          seed: 42,
+          thinkingLevel: "HIGH",
+          includeThoughts: true,
+          futureOption: true,
+          imageConfig: { aspectRatio: "4:3", nativeImageOption: true },
+          thinkingConfig: { thinkingLevel: "LOW", nativeThinkingOption: true },
+        },
+        http: {
+          body: {
+            safetySettings: [],
+            generationConfig: {
+              imageConfig: { aspectRatio: "3:2", httpImageOption: true },
+              thinkingConfig: { includeThoughts: false, httpThinkingOption: true },
+              futureOption: "http",
+              httpOption: true,
+            },
+          },
+          headers: { "x-request": "yes" },
+          query: { trace: "1" },
+        },
+      })
+
+      expect(response.images).toHaveLength(3)
+      expect(response.images.map((image) => image.data)).toEqual([
+        Uint8Array.from([1, 2, 3]),
+        Uint8Array.from([4, 5, 6]),
+        Uint8Array.from([7, 8, 9]),
+      ])
+      expect(response.images.map((image) => image.mediaType)).toEqual(["image/png", "image/jpeg", "image/webp"])
+      expect(response.images[0].providerMetadata).toMatchObject({ google: { thoughtSignature: "signature-1" } })
+      expect(response.images[1].providerMetadata).toMatchObject({
+        google: { candidateIndex: 0, partIndex: 3, finishReason: "STOP" },
+      })
+      expect(response.images[2].providerMetadata).toMatchObject({ google: { candidateIndex: 7, partIndex: 0 } })
+      expect(response.usage?.inputTokens).toBe(5)
+      expect(response.usage?.outputTokens).toBe(10)
+      expect(response.usage?.reasoningTokens).toBe(3)
+      expect(response.usage?.providerMetadata).toMatchObject({ google: { serviceTier: "STANDARD" } })
+      expect(response.providerMetadata).toEqual({
+        google: {
+          modelVersion: "gemini-3.1-flash-image",
+          responseId: "response-1",
+          promptFeedback: undefined,
+          candidates: [
+            {
+              index: 0,
+              finishReason: "STOP",
+              finishMessage: undefined,
+              safetyRatings: [{ category: "safe" }],
+              citationMetadata: undefined,
+              groundingMetadata: undefined,
+              parts: [
+                {
+                  type: "inlineData",
+                  mediaType: "image/png",
+                  thought: undefined,
+                  thoughtSignature: "signature-1",
+                },
+                { type: "text", text: "planning", thought: true, thoughtSignature: "text-signature" },
+                {
+                  type: "inlineData",
+                  mediaType: "image/png",
+                  thought: true,
+                  thoughtSignature: "draft-signature",
+                },
+                {
+                  type: "inlineData",
+                  mediaType: "image/jpeg",
+                  thought: undefined,
+                  thoughtSignature: undefined,
+                },
+              ],
+            },
+            {
+              index: 7,
+              finishReason: undefined,
+              finishMessage: undefined,
+              safetyRatings: undefined,
+              citationMetadata: undefined,
+              groundingMetadata: undefined,
+              parts: [
+                {
+                  type: "inlineData",
+                  mediaType: "image/webp",
+                  thought: undefined,
+                  thoughtSignature: undefined,
+                },
+              ],
+            },
+          ],
+        },
+      })
+    }).pipe(
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse((input) =>
+              Effect.gen(function* () {
+                const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
+                expect(request.url).toBe(
+                  "https://generativelanguage.test/v1beta/models/any-model-id:generateContent?api=v1&trace=1",
+                )
+                expect(request.headers.get("x-goog-api-key")).toBe("test")
+                expect(request.headers.get("x-default")).toBe("yes")
+                expect(request.headers.get("x-request")).toBe("yes")
+                expect(JSON.parse(input.text)).toEqual({
+                  contents: [{ role: "user", parts: [{ text: "A robot tending a rooftop garden" }] }],
+                  generationConfig: {
+                    responseModalities: ["IMAGE"],
+                    imageConfig: {
+                      aspectRatio: "3:2",
+                      imageSize: "2K",
+                      nativeImageOption: true,
+                      httpImageOption: true,
+                    },
+                    seed: 42,
+                    thinkingConfig: {
+                      thinkingLevel: "LOW",
+                      includeThoughts: false,
+                      nativeThinkingOption: true,
+                      httpThinkingOption: true,
+                    },
+                    futureOption: "http",
+                    httpOption: true,
+                  },
+                  labels: { deployment: "test" },
+                  safetySettings: [],
+                })
+                return input.respond(
+                  JSON.stringify({
+                    candidates: [
+                      {
+                        content: {
+                          parts: [
+                            {
+                              inlineData: { mimeType: "image/png", data: "AQID" },
+                              thoughtSignature: "signature-1",
+                            },
+                            { text: "planning", thought: true, thoughtSignature: "text-signature" },
+                            {
+                              inlineData: { mimeType: "image/png", data: "CgsM" },
+                              thought: true,
+                              thoughtSignature: "draft-signature",
+                            },
+                            { inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
+                          ],
+                        },
+                        finishReason: "STOP",
+                        safetyRatings: [{ category: "safe" }],
+                      },
+                      {
+                        index: 7,
+                        content: { parts: [{ inlineData: { mimeType: "image/webp", data: "BwgJ" } }] },
+                      },
+                    ],
+                    usageMetadata: {
+                      promptTokenCount: 5,
+                      candidatesTokenCount: 7,
+                      thoughtsTokenCount: 3,
+                      totalTokenCount: 15,
+                      serviceTier: "STANDARD",
+                    },
+                    modelVersion: "gemini-3.1-flash-image",
+                    responseId: "response-1",
+                  }),
+                  { headers: { "content-type": "application/json" } },
+                )
+              }),
+            ),
+          ),
+        ),
+      ),
+    ),
+  )
+
+  it.effect("includes Google diagnostics when no final image is returned", () =>
+    Image.generate({
+      model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta" }).image(
+        "gemini-3.1-flash-image",
+      ),
       prompt: "A robot tending a rooftop garden",
-      count: -1,
-      size: { width: -1, height: 0.5 },
-      providerOptions: { openai: { outputCompression: 101 } },
     }).pipe(
       Effect.flip,
       Effect.tap((error) =>
         Effect.sync(() => {
-          expect(error.reason._tag).toBe("InvalidRequest")
+          expect(error.reason._tag).toBe("InvalidProviderOutput")
+          if (error.reason._tag !== "InvalidProviderOutput") return
+          expect(error.reason.message).toContain("finish reasons: IMAGE_SAFETY")
+          expect(error.reason.providerMetadata).toEqual({
+            google: {
+              promptFeedback: { blockReason: "SAFETY" },
+              candidates: [
+                {
+                  index: 0,
+                  finishReason: "IMAGE_SAFETY",
+                  finishMessage: "The generated image was blocked by safety filters.",
+                  safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
+                  citationMetadata: undefined,
+                  groundingMetadata: undefined,
+                  parts: [{ type: "text", text: "blocked", thought: false, thoughtSignature: undefined }],
+                },
+              ],
+            },
+          })
         }),
       ),
       Effect.provide(
         ImageClient.layer.pipe(
-          Layer.provide(dynamicResponse(() => Effect.die("invalid request should not reach the provider"))),
+          Layer.provide(
+            dynamicResponse((input) =>
+              Effect.succeed(
+                input.respond(
+                  JSON.stringify({
+                    candidates: [
+                      {
+                        content: { parts: [{ text: "blocked", thought: false }] },
+                        finishReason: "IMAGE_SAFETY",
+                        finishMessage: "The generated image was blocked by safety filters.",
+                        safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
+                      },
+                    ],
+                    promptFeedback: { blockReason: "SAFETY" },
+                  }),
+                  { headers: { "content-type": "application/json" } },
+                ),
+              ),
+            ),
+          ),
         ),
       ),
     ),

+ 161 - 0
packages/ai/test/image.types.ts

@@ -0,0 +1,161 @@
+import {
+  Image,
+  ImageInput,
+  ImageModel,
+  type ImageModelOptions,
+  type ImageOptions,
+  type ImageRequestFor,
+  type ImageRoute,
+} from "../src"
+import { Google, OpenAI, XAI, ZAI } from "../src/providers"
+
+type GoogleLikeOptions = {
+  readonly aspectRatio?: "1:1" | "16:9"
+  readonly imageSize?: "1K" | "2K"
+} & Record<string, unknown>
+
+declare const route: ImageRoute<GoogleLikeOptions>
+const google = ImageModel.make<GoogleLikeOptions>({ id: "gemini-image", provider: "google", route })
+// @ts-expect-error Extracted model options retain known provider fields.
+const invalidGoogleOptions: ImageModelOptions<typeof google> = { aspectRatio: "wide" }
+void invalidGoogleOptions
+
+Image.generate({
+  model: google,
+  prompt: "A lighthouse",
+  images: [
+    ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
+    ImageInput.url("data:image/jpeg;base64,AQID"),
+    ImageInput.fileUri("https://generativelanguage.googleapis.com/v1beta/files/example", "image/webp"),
+  ],
+  options: { aspectRatio: "16:9", imageSize: "2K", futureOption: true },
+})
+
+const googleProvider = Google.configure({ apiKey: "test" }).image("any-model-id")
+Image.generate({
+  model: googleProvider,
+  prompt: "A lighthouse",
+  options: {
+    aspectRatio: "16:9",
+    imageSize: "2K",
+    seed: 42,
+    thinkingLevel: "HIGH",
+    includeThoughts: true,
+    futureOption: true,
+  },
+})
+Image.generate({
+  model: googleProvider,
+  prompt: "A lighthouse",
+  options: { aspectRatio: "future-ratio", imageSize: "8K", thinkingLevel: "FUTURE" },
+})
+// @ts-expect-error Image generation options are request-scoped, not provider configuration.
+Google.configure({ image: { providerOptions: { imageSize: "2K" } } })
+// @ts-expect-error Known Google string options retain their value kind.
+Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { imageSize: 2 } })
+// @ts-expect-error Known Google numeric options retain their value kind.
+Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { seed: "42" } })
+// @ts-expect-error Known Google boolean options retain their value kind.
+Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { includeThoughts: "yes" } })
+
+const openai = OpenAI.image("gpt-image-2")
+// @ts-expect-error Image generation options are request-scoped, not provider configuration.
+OpenAI.configure({ image: { options: { quality: "medium" } } })
+const futureOpenAIOptions: ImageModelOptions<typeof openai> = { quality: "future-quality" }
+void futureOpenAIOptions
+Image.generate({
+  model: openai,
+  prompt: "A lighthouse",
+  images: [ImageInput.url("https://example.com/source.png"), ImageInput.file("file_123")],
+  options: {
+    mask: ImageInput.bytes(Uint8Array.from([1]), "image/png"),
+    quality: "hd",
+    outputFormat: "webp",
+    size: "2048x2048",
+    future_option: true,
+  },
+})
+Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: "future-quality", size: "256x256" } })
+Image.generate({ model: openai, prompt: "A lighthouse", options: { size: "1792x1024" } })
+Image.generate({ model: openai, prompt: "A lighthouse", options: { native_future_option: true } })
+// @ts-expect-error Known OpenAI string options retain their value kind.
+Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: 1 } })
+// @ts-expect-error Known OpenAI numeric options retain their value kind.
+Image.generate({ model: openai, prompt: "A lighthouse", options: { outputCompression: "80" } })
+OpenAI.imageGeneration({ action: "future-action", quality: "future-quality", size: "2048x2048" })
+// @ts-expect-error Hosted image generation numeric options retain their value kind.
+OpenAI.imageGeneration({ partialImages: "2" })
+// @ts-expect-error Known Google-like options are inferred from the selected model.
+Image.generate({ model: google, prompt: "A lighthouse", options: { aspectRatio: "wide" } })
+
+const xai = XAI.configure({ apiKey: "test" }).image("any-model-id")
+// @ts-expect-error Image generation options are request-scoped, not provider configuration.
+XAI.configure({ image: { options: { resolution: "1k" } } })
+Image.generate({
+  model: xai,
+  prompt: "A lighthouse",
+  images: [ImageInput.url("data:image/png;base64,AQID"), ImageInput.file("file_123")],
+  options: {
+    n: 2,
+    aspectRatio: "future-ratio",
+    resolution: "future-resolution",
+    responseFormat: "future-format",
+    future_option: true,
+  },
+})
+Image.generate({
+  model: xai,
+  prompt: "A lighthouse",
+  options: { aspect_ratio: "16:9", response_format: "b64_json", native_future_option: true },
+})
+// @ts-expect-error Known xAI numeric options retain their value kind.
+Image.generate({ model: xai, prompt: "A lighthouse", options: { n: "2" } })
+// @ts-expect-error Known xAI string options retain their value kind.
+Image.generate({ model: xai, prompt: "A lighthouse", options: { resolution: 2 } })
+
+const zai = ZAI.configure({ apiKey: "test" }).image("any-model-id")
+// @ts-expect-error Image generation options are request-scoped, not provider configuration.
+ZAI.configure({ image: { options: { quality: "hd" } } })
+Image.generate({
+  model: zai,
+  prompt: "A lighthouse",
+  options: { quality: "future-quality", userID: "user-123", future_option: true },
+})
+Image.generate({ model: zai, prompt: "A lighthouse", options: { user_id: "raw-user" } })
+// @ts-expect-error Known Z.ai string options retain their value kind.
+Image.generate({ model: zai, prompt: "A lighthouse", options: { quality: 1 } })
+// @ts-expect-error Known Z.ai user IDs retain their value kind.
+Image.generate({ model: zai, prompt: "A lighthouse", options: { userID: 1 } })
+
+declare const generic: ImageModel<ImageOptions>
+Image.generate({ model: generic, prompt: "A lighthouse", options: { arbitrary: true } })
+const explicitImageInput: ImageInput = ImageInput.url("https://example.com/image.png")
+void explicitImageInput
+
+// @ts-expect-error Raw strings are ambiguous and are not image inputs.
+Image.generate({ model: openai, prompt: "A lighthouse", images: ["AQID"] })
+// @ts-expect-error Byte image inputs require an explicit MIME type.
+Image.generate({ model: openai, prompt: "A lighthouse", images: [{ type: "bytes", data: new Uint8Array() }] })
+// @ts-expect-error File URIs require an explicit MIME type for Gemini fileData.
+Image.generate({ model: google, prompt: "A lighthouse", images: [{ type: "file-uri", uri: "files/123" }] })
+
+const request = Image.request({
+  model: google,
+  prompt: "A lighthouse",
+  options: { aspectRatio: "1:1", futureOption: true },
+})
+const typedRequest: ImageRequestFor<GoogleLikeOptions> = request
+void typedRequest
+
+// @ts-expect-error Image requests no longer expose a common count option.
+Image.generate({ model: openai, prompt: "A lighthouse", count: 2 })
+// @ts-expect-error Image requests no longer expose a common size option.
+Image.generate({ model: openai, prompt: "A lighthouse", size: { width: 1024, height: 1024 } })
+// @ts-expect-error Image requests no longer expose a common aspectRatio option.
+Image.generate({ model: openai, prompt: "A lighthouse", aspectRatio: "16:9" })
+// @ts-expect-error Image requests no longer expose a common seed option.
+Image.generate({ model: openai, prompt: "A lighthouse", seed: 1 })
+// @ts-expect-error Image requests do not expose metadata.
+Image.generate({ model: openai, prompt: "A lighthouse", metadata: { trace: true } })
+// @ts-expect-error Masks are provider options, not a common image request field.
+Image.generate({ model: openai, prompt: "A lighthouse", mask: ImageInput.url("https://example.com/mask.png") })

+ 29 - 0
packages/ai/test/lib/image.ts

@@ -0,0 +1,29 @@
+export const dimensions = (data: Uint8Array) => {
+  if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
+    return {
+      width: readUint32(data, 16),
+      height: readUint32(data, 20),
+    }
+  if (data[0] === 0xff && data[1] === 0xd8) {
+    for (let offset = 2; offset + 8 < data.length; ) {
+      if (data[offset] !== 0xff) {
+        offset++
+        continue
+      }
+      const marker = data[offset + 1]
+      if (
+        marker !== undefined &&
+        [0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)
+      )
+        return {
+          width: (data[offset + 7] << 8) | data[offset + 8],
+          height: (data[offset + 5] << 8) | data[offset + 6],
+        }
+      offset += 2 + ((data[offset + 2] << 8) | data[offset + 3])
+    }
+  }
+  throw new Error("Unsupported image fixture format")
+}
+
+const readUint32 = (data: Uint8Array, offset: number) =>
+  ((data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]) >>> 0

+ 24 - 2
packages/ai/test/provider-error.test.ts

@@ -3,8 +3,30 @@ import { isContextOverflow } from "../src"
 import { classifyProviderFailure } from "../src/provider-error"
 
 describe("provider error classification", () => {
-  test("classifies Z.AI GLM token limit messages as context overflow", () => {
-    expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
+  test("classifies provider token limit messages as context overflow", () => {
+    const messages = [
+      "tokens in request more than max tokens allowed",
+      '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
+      "Requested token count exceeds the model's maximum context length of 131072 tokens.",
+      "Input length (265330) exceeds model's maximum context length (262144).",
+      "Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
+      "The input (516368 tokens) is longer than the model's context length (262144 tokens).",
+      "Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
+      "Too many tokens",
+      "Token limit exceeded",
+    ]
+
+    expect(messages.every(isContextOverflow)).toBe(true)
+  })
+
+  test("does not classify rate limits as context overflow", () => {
+    const messages = [
+      "Throttling error: Too many tokens, please wait before trying again.",
+      "Rate limit exceeded, please retry after 30 seconds.",
+      "Too many requests. Please slow down.",
+    ]
+
+    expect(messages.some(isContextOverflow)).toBe(false)
   })
 
   test("classifies V1 plain-text rate limit fallbacks", () => {

+ 54 - 1
packages/ai/test/provider/cloudflare.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect } from "bun:test"
 import { ConfigProvider, Effect, Schema } from "effect"
 import { HttpClientRequest } from "effect/unstable/http"
-import { LLM } from "../../src"
+import { LLM, LLMEvent } from "../../src"
 import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
 import { LLMClient } from "../../src/route"
 import { it } from "../lib/effect"
@@ -83,6 +83,59 @@ describe("Cloudflare", () => {
     }),
   )
 
+  it.effect("preserves reasoning details for AI Gateway continuation", () =>
+    Effect.gen(function* () {
+      const model = CloudflareAIGateway.configure({
+        accountId: "test-account",
+        gatewayId: "test-gateway",
+        apiKey: "test-token",
+      }).model("anthropic/claude-sonnet-4.6")
+      const details = [
+        { type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
+        { type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
+        { type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
+      ]
+      const merged = [
+        {
+          type: "reasoning.text",
+          text: "Thinking",
+          signature: "signed",
+          format: "anthropic-claude-v1",
+          index: 0,
+        },
+      ]
+      const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
+        Effect.provide(
+          dynamicResponse((input) =>
+            Effect.succeed(
+              input.respond(
+                sseEvents(
+                  deltaChunk({ reasoning: "Think", reasoning_details: [details[0]] }),
+                  deltaChunk({ reasoning: "ing", reasoning_details: [details[1]] }),
+                  deltaChunk({ reasoning_details: [details[2]] }),
+                  deltaChunk({ content: "Hello" }),
+                  deltaChunk({}, "stop"),
+                ),
+                { headers: { "content-type": "text/event-stream" } },
+              ),
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("Thinking")
+      expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningField: "reasoning", reasoningDetails: merged },
+      })
+
+      const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
+      expect(replay.body.messages).toEqual([
+        { role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
+      ])
+    }),
+  )
+
   it.effect("defaults AI Gateway id to default when omitted or blank", () =>
     Effect.gen(function* () {
       expect(

+ 56 - 0
packages/ai/test/provider/google-images.recorded.test.ts

@@ -0,0 +1,56 @@
+import { describe, expect } from "bun:test"
+import { Effect } from "effect"
+import { Image, ImageInput } from "../../src"
+import { Google } from "../../src/providers"
+import { dimensions } from "../lib/image"
+import { recordedTests } from "../recorded-test"
+
+const model = Google.configure({
+  apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture",
+}).image("gemini-3.1-flash-image")
+
+const recorded = recordedTests({
+  prefix: "google-images",
+  provider: "google",
+  protocol: "google-images",
+  requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
+})
+
+describe("Google Images recorded", () => {
+  recorded.effect("generates an image", () =>
+    Effect.gen(function* () {
+      const response = yield* Image.generate({
+        model,
+        prompt: "A simple flat blue circle centered on a plain white background.",
+        options: { aspectRatio: "1:1" },
+      })
+
+      expect(response.images).toHaveLength(1)
+      expect(response.image?.mediaType).toMatch(/^image\//)
+      expect(response.image?.data).toBeInstanceOf(Uint8Array)
+      expect(response.image?.data.length).toBeGreaterThan(0)
+    }),
+  )
+
+  recorded.effect("edits an image", () =>
+    Effect.gen(function* () {
+      const response = yield* Image.generate({
+        model,
+        prompt:
+          "Transform this minimal source into a bright orange sun icon with eight rounded rays on a pale blue background.",
+        images: [
+          ImageInput.bytes(
+            yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
+            "image/jpeg",
+          ),
+        ],
+        options: { aspectRatio: "1:1" },
+      })
+
+      expect(response.image?.mediaType).toBe("image/jpeg")
+      expect(response.image?.data).toBeInstanceOf(Uint8Array)
+      if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned Google image bytes")
+      expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
+    }),
+  )
+})

+ 78 - 3
packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts

@@ -1,10 +1,12 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
-import { LLM, LLMEvent } from "../../src"
+import { LLM, LLMEvent, LLMResponse } from "../../src"
+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 { recordedTests } from "../recorded-test"
+import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
 
 const cases = [
   {
@@ -15,6 +17,7 @@ const cases = [
     }).model("anthropic/claude-sonnet-4.6"),
     requires: ["OPENROUTER_API_KEY"],
     cassette: "openrouter-reasoning",
+    structured: true,
   },
   {
     name: "Vercel AI Gateway",
@@ -26,6 +29,7 @@ const cases = [
     }).model("anthropic/claude-sonnet-4.6"),
     requires: ["AI_GATEWAY_API_KEY"],
     cassette: "vercel-ai-gateway-reasoning",
+    structured: true,
   },
 ] as const
 
@@ -57,11 +61,82 @@ for (const item of cases) {
           expect(response.text.replaceAll(",", "").trim()).toBe("37887")
           expect(response.reasoning.length).toBeGreaterThan(0)
           expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
-          expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
-            openai: { reasoningField: "reasoning" },
+          const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
+          expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
+          expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
+          if (!item.structured) return
+          const details = metadata?.openai?.reasoningDetails
+          if (!Array.isArray(details)) return
+          expect(
+            details.some(
+              (detail) =>
+                typeof detail === "object" &&
+                detail !== null &&
+                "signature" in detail &&
+                typeof detail.signature === "string" &&
+                detail.signature.length > 0,
+            ),
+          ).toBe(true)
+
+          const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+            LLM.request({ model: item.model, messages: [response.message] }),
+          )
+          expect(replay.body.messages).toMatchObject([
+            { role: "assistant", content: response.text, reasoning: response.reasoning },
+          ])
+          const replayDetails =
+            replay.body.messages[0]?.role === "assistant" ? replay.body.messages[0].reasoning_details : undefined
+          expect(Array.isArray(replayDetails)).toBe(true)
+          if (!Array.isArray(replayDetails)) return
+          expect(replayDetails).toEqual(details)
+          expect(replayDetails).toHaveLength(1)
+          expect(replayDetails[0]).toMatchObject({
+            type: "reasoning.text",
+            text: response.reasoning,
+            signature: expect.any(String),
           })
         }),
       30_000,
     )
+
+    recorded.effect.with(
+      "continues signed reasoning through a tool loop",
+      { cassette: `${item.cassette}-tool-loop`, tags: ["continuation", "tool", "tool-loop"] },
+      () =>
+        Effect.gen(function* () {
+          const events = yield* runWeatherToolLoop(
+            goldenWeatherToolLoopRequest({
+              id: `${item.cassette}-tool-loop`,
+              model: item.model,
+              maxTokens: 1536,
+              temperature: false,
+            }),
+          )
+
+          expectWeatherToolLoop(events)
+          expect(
+            LLMResponse.text({
+              events: events.slice(events.findIndex(LLMEvent.is.stepFinish) + 1),
+            }).trim(),
+          ).toMatch(/^Paris is sunny\.?$/)
+          const details = events
+            .filter(LLMEvent.is.reasoningEnd)
+            .map((event) => event.providerMetadata?.openai?.reasoningDetails)
+            .find(Array.isArray)
+          expect(Array.isArray(details)).toBe(item.structured)
+          if (!item.structured || !Array.isArray(details)) return
+          expect(
+            details.some(
+              (detail) =>
+                typeof detail === "object" &&
+                detail !== null &&
+                "signature" in detail &&
+                typeof detail.signature === "string" &&
+                detail.signature.length > 0,
+            ),
+          ).toBe(true)
+        }),
+      60_000,
+    )
   })
 }

+ 430 - 0
packages/ai/test/provider/openai-chat.test.ts

@@ -570,6 +570,375 @@ describe("OpenAI Chat route", () => {
     }),
   )
 
+  it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
+    Effect.gen(function* () {
+      const details = [
+        { type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
+        { type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 },
+      ]
+      const response = yield* LLMClient.generate(
+        LLM.updateRequest(request, {
+          tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
+        }),
+      ).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
+              { choices: [{ delta: { reasoning_details: [details[1]] } }] },
+              {
+                choices: [
+                  {
+                    delta: {
+                      tool_calls: [
+                        { index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } },
+                      ],
+                    },
+                    finish_reason: "tool_calls",
+                  },
+                ],
+              },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("thinking")
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningField: "reasoning", reasoningDetails: details },
+      })
+
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(replay.body.messages).toEqual([
+        {
+          role: "assistant",
+          content: null,
+          reasoning: "thinking",
+          reasoning_details: details,
+          tool_calls: [
+            {
+              id: "call_1",
+              type: "function",
+              function: { name: "lookup", arguments: '{"query":"weather"}' },
+            },
+          ],
+        },
+      ])
+    }),
+  )
+
+  it.effect("uses reasoning details as display fallback without inventing a scalar replay field", () =>
+    Effect.gen(function* () {
+      const details = [
+        { type: "reasoning.summary", summary: "thinking", format: "openai-responses-v1", index: 0 },
+        { type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
+      ]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning_details: [details[0]] } }] },
+              { choices: [{ delta: { reasoning_details: [details[1]] } }] },
+              { choices: [{ delta: { content: "Hello" } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("thinking")
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningDetails: details },
+      })
+
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
+    }),
+  )
+
+  it.effect("preserves unknown reasoning details while using scalar display text", () =>
+    Effect.gen(function* () {
+      const details = [{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } }]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
+              { choices: [{ delta: { content: "Hello" } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("thinking")
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningField: "reasoning", reasoningDetails: details },
+      })
+
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(replay.body.messages).toEqual([
+        { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
+      ])
+    }),
+  )
+
+  it.effect("uses scalar display text for signature-only reasoning details", () =>
+    Effect.gen(function* () {
+      const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
+              { choices: [{ delta: { content: "Hello" } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("thinking")
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningField: "reasoning", reasoningDetails: details },
+      })
+    }),
+  )
+
+  it.effect("ignores scalar reasoning after content starts", () =>
+    Effect.gen(function* () {
+      const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning_details: details } }] },
+              { choices: [{ delta: { content: "Hello" } }] },
+              { choices: [{ delta: { reasoning: "scalar" } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("detail")
+      expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
+      expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningDetails: details },
+      })
+    }),
+  )
+
+  it.effect("preserves an explicitly empty reasoning details array", () =>
+    Effect.gen(function* () {
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning_details: [] } }] },
+              { choices: [{ delta: { content: "Hello" } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("")
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningDetails: [] },
+      })
+
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
+    }),
+  )
+
+  it.effect("attaches signature-only details that arrive after content", () =>
+    Effect.gen(function* () {
+      const details = [
+        { type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
+        { type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
+      ]
+      const merged = [
+        {
+          type: "reasoning.text",
+          text: "thinking",
+          signature: "signed",
+          format: "anthropic-claude-v1",
+          index: 0,
+        },
+      ]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
+              { choices: [{ delta: { content: "Hello" } }] },
+              { choices: [{ delta: { reasoning_details: [details[1]] } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("thinking")
+      expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningField: "reasoning", reasoningDetails: merged },
+      })
+      expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
+      expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
+      expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
+      expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
+        openai: { reasoningField: "reasoning", reasoningDetails: merged },
+      })
+      expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
+        response.events.findIndex(LLMEvent.is.textStart),
+      )
+
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(replay.body.messages).toEqual([
+        { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
+      ])
+    }),
+  )
+
+  it.effect("preserves metadata-only reasoning when the stream ends", () =>
+    Effect.gen(function* () {
+      const details = [{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 }]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning_details: details } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.message.content).toEqual([
+        { type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: details } } },
+      ])
+      expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
+      expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
+
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
+    }),
+  )
+
+  it.effect("flushes details-only display reasoning when the stream ends", () =>
+    Effect.gen(function* () {
+      const details = [{ type: "reasoning.summary", summary: "summary", format: "openai-responses-v1", index: 0 }]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning_details: details } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("summary")
+      expect(response.message.content).toEqual([
+        { type: "reasoning", text: "summary", providerMetadata: { openai: { reasoningDetails: details } } },
+      ])
+    }),
+  )
+
+  it.effect("replays details from multiple reasoning parts in order", () =>
+    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<OpenAIChat.OpenAIChatBody>(
+        LLM.request({
+          model,
+          messages: [
+            Message.assistant([
+              {
+                type: "reasoning",
+                text: "first",
+                providerMetadata: { openai: { reasoningDetails: [first] } },
+              },
+              {
+                type: "reasoning",
+                text: "second",
+                providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: [second] } },
+              },
+            ]),
+          ],
+        }),
+      )
+
+      expect(replay.body.messages).toEqual([
+        { role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
+      ])
+    }),
+  )
+
+  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<OpenAIChat.OpenAIChatBody>(
+        LLM.request({
+          model,
+          messages: [
+            Message.assistant([
+              {
+                type: "reasoning",
+                text: "A",
+                providerMetadata: { openai: { reasoningDetails: [detail] } },
+              },
+              { type: "reasoning", text: "B" },
+            ]),
+          ],
+        }),
+      )
+
+      expect(replay.body.messages).toEqual([
+        { role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
+      ])
+    }),
+  )
+
+  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<OpenAIChat.OpenAIChatBody>(
+        LLM.request({
+          model,
+          messages: [
+            Message.make({
+              role: "assistant",
+              content: [{ type: "reasoning", text: "thinking" }],
+              native: { openaiCompatible: { reasoning_content: "thinking", reasoning_details: details } },
+            }),
+          ],
+        }),
+      )
+
+      expect(replay.body.messages).toEqual([
+        { role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
+      ])
+    }),
+  )
+
   it.effect("assembles streamed tool call input", () =>
     Effect.gen(function* () {
       const body = sseEvents(
@@ -606,6 +975,67 @@ describe("OpenAI Chat route", () => {
     }),
   )
 
+  it.effect("ignores empty identity fields on later tool call deltas", () =>
+    Effect.gen(function* () {
+      const body = sseEvents(
+        deltaChunk({
+          tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{" } }],
+        }),
+        deltaChunk({
+          tool_calls: [{ index: 0, id: "", function: { name: "", arguments: '\"query\":\"weather\"}' } }],
+        }),
+        deltaChunk({}, "tool_calls"),
+      )
+      const response = yield* LLMClient.generate(
+        LLM.updateRequest(request, {
+          tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
+        }),
+      ).pipe(Effect.provide(fixedResponse(body)))
+
+      expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
+    }),
+  )
+
+  it.effect("buffers tool call deltas until the function name arrives", () =>
+    Effect.gen(function* () {
+      const body = sseEvents(
+        deltaChunk({
+          tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{" } }],
+        }),
+        deltaChunk({
+          tool_calls: [{ index: 0, function: { name: "lookup", arguments: '\"query\":' } }],
+        }),
+        deltaChunk({ tool_calls: [{ index: 0, function: { arguments: '\"weather\"}' } }] }),
+        deltaChunk({}, "tool_calls"),
+      )
+      const response = yield* LLMClient.generate(
+        LLM.updateRequest(request, {
+          tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
+        }),
+      ).pipe(Effect.provide(fixedResponse(body)))
+
+      expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
+    }),
+  )
+
+  it.effect("fails when a buffered tool call never receives a function name", () =>
+    Effect.gen(function* () {
+      const body = sseEvents(
+        deltaChunk({
+          tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }],
+        }),
+        deltaChunk({}, "tool_calls"),
+      )
+      const error = yield* LLMClient.generate(
+        LLM.updateRequest(request, {
+          tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
+        }),
+      ).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
+
+      expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
+    }),
+  )
+
   it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
     Effect.gen(function* () {
       const body = sseEvents(

+ 31 - 9
packages/ai/test/provider/openai-images.recorded.test.ts

@@ -1,18 +1,12 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
-import { Image } from "../../src"
+import { Image, ImageInput } from "../../src"
 import { OpenAI } from "../../src/providers"
+import { dimensions } from "../lib/image"
 import { recordedTests } from "../recorded-test"
 
 const model = OpenAI.configure({
   apiKey: process.env.OPENAI_API_KEY ?? "fixture",
-  image: {
-    providerOptions: {
-      quality: "low",
-      outputFormat: "jpeg",
-      outputCompression: 10,
-    },
-  },
 }).image("gpt-image-1-mini")
 
 const recorded = recordedTests({
@@ -28,7 +22,7 @@ describe("OpenAI Images recorded", () => {
       const response = yield* Image.generate({
         model,
         prompt: "A simple flat black circle centered on a plain white background.",
-        size: { width: 1024, height: 1024 },
+        options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
       })
 
       expect(response.images).toHaveLength(1)
@@ -37,4 +31,32 @@ describe("OpenAI Images recorded", () => {
       expect(response.image?.data.length).toBeGreaterThan(0)
     }),
   )
+
+  recorded.effect.with(
+    "edits an image",
+    {
+      options: {
+        match: (incoming, recorded) => incoming.method === recorded.method && incoming.url === recorded.url,
+      },
+    },
+    () =>
+      Effect.gen(function* () {
+        const response = yield* Image.generate({
+          model,
+          prompt: "Keep the simple shape and change it from black to bright green.",
+          images: [
+            ImageInput.bytes(
+              yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
+              "image/jpeg",
+            ),
+          ],
+          options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
+        })
+
+        expect(response.image?.mediaType).toBe("image/jpeg")
+        expect(response.image?.data).toBeInstanceOf(Uint8Array)
+        if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned OpenAI image bytes")
+        expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
+      }),
+  )
 })

+ 99 - 1
packages/ai/test/provider/openrouter.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
-import { LLM } from "../../src"
+import { LLM, Message } from "../../src"
 import { LLMClient } from "../../src/route"
 import * as OpenRouter from "../../src/providers/openrouter"
 import { it } from "../lib/effect"
@@ -53,4 +53,102 @@ describe("OpenRouter", () => {
       })
     }),
   )
+
+  it.effect("preserves manually supplied reasoning details", () =>
+    Effect.gen(function* () {
+      const details = [
+        { type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
+        { type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
+        { 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<OpenRouter.OpenRouterBody>(
+        LLM.request({
+          model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
+          messages: [
+            Message.assistant([
+              {
+                type: "reasoning",
+                text: "Thinking",
+                providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
+              },
+            ]),
+          ],
+        }),
+      )
+
+      expect(prepared.body.messages).toEqual([
+        {
+          role: "assistant",
+          content: null,
+          reasoning: "Thinking",
+          reasoning_details: details,
+        },
+      ])
+    }),
+  )
+
+  it.effect("preserves opaque and duplicate continuation details", () =>
+    Effect.gen(function* () {
+      const details = [
+        { type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
+        { type: "reasoning.encrypted", id: "state", data: "opaque" },
+        { type: "reasoning.encrypted", id: "state", data: "opaque" },
+      ]
+      const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
+        LLM.request({
+          model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
+          messages: [
+            Message.assistant({
+              type: "reasoning",
+              text: "Thinking",
+              providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
+            }),
+          ],
+        }),
+      )
+
+      expect(prepared.body.messages).toEqual([
+        { role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
+      ])
+    }),
+  )
+
+  it.effect("does not merge distinct adjacent reasoning text blocks", () =>
+    Effect.gen(function* () {
+      const details = [
+        { 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<OpenRouter.OpenRouterBody>(
+        LLM.request({
+          model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
+          messages: [
+            Message.assistant({
+              type: "reasoning",
+              text: "AB",
+              providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
+            }),
+          ],
+        }),
+      )
+
+      expect(prepared.body.messages).toEqual([
+        { role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
+      ])
+    }),
+  )
+
+  it.effect("omits scalar reasoning without continuation details", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
+        LLM.request({
+          model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
+          messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
+        }),
+      )
+
+      expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
+    }),
+  )
 })

+ 55 - 0
packages/ai/test/provider/xai-images.recorded.test.ts

@@ -0,0 +1,55 @@
+import { describe, expect } from "bun:test"
+import { Effect } from "effect"
+import { Image, ImageInput } from "../../src"
+import { XAI } from "../../src/providers"
+import { dimensions } from "../lib/image"
+import { recordedTests } from "../recorded-test"
+
+const model = XAI.configure({
+  apiKey: process.env.XAI_API_KEY ?? "fixture",
+}).image("grok-imagine-image")
+
+const recorded = recordedTests({
+  prefix: "xai-images",
+  provider: "xai",
+  protocol: "xai-images",
+  requires: ["XAI_API_KEY"],
+})
+
+describe("xAI Images recorded", () => {
+  recorded.effect("generates an image", () =>
+    Effect.gen(function* () {
+      const response = yield* Image.generate({
+        model,
+        prompt: "A simple flat black diamond centered on a plain white background.",
+        options: { aspectRatio: "1:1", resolution: "1k", responseFormat: "b64_json" },
+      })
+
+      expect(response.images).toHaveLength(1)
+      expect(response.image?.mediaType.startsWith("image/")).toBe(true)
+      expect(response.image?.data).toBeInstanceOf(Uint8Array)
+      expect(response.image?.data.length).toBeGreaterThan(0)
+    }),
+  )
+
+  recorded.effect("edits an image", () =>
+    Effect.gen(function* () {
+      const response = yield* Image.generate({
+        model,
+        prompt: "Keep the simple shape and change it from black to bright purple.",
+        images: [
+          ImageInput.bytes(
+            yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
+            "image/jpeg",
+          ),
+        ],
+        options: { aspectRatio: "1:1", resolution: "1k", responseFormat: "b64_json" },
+      })
+
+      expect(response.image?.mediaType).toMatch(/^image\/(jpeg|png)$/)
+      expect(response.image?.data).toBeInstanceOf(Uint8Array)
+      if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned xAI image bytes")
+      expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
+    }),
+  )
+})

+ 109 - 0
packages/ai/test/provider/xai-images.test.ts

@@ -0,0 +1,109 @@
+import { describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
+import { Headers, HttpClientRequest } from "effect/unstable/http"
+import { Image, ImageClient } from "../../src"
+import { XAI } from "../../src/providers"
+import { Auth } from "../../src/route"
+import { it } from "../lib/effect"
+import { dynamicResponse } from "../lib/http"
+
+describe("xAI Images", () => {
+  it.effect("generates through the OpenAI-compatible Images API", () =>
+    Effect.gen(function* () {
+      const response = yield* Image.generate({
+        model: XAI.configure({
+          apiKey: "test",
+          baseURL: "https://api.xai.test/v1",
+          http: { body: { configured: true }, headers: { "x-default": "yes" } },
+        }).image("grok-imagine-image"),
+        prompt: "A robot tending a rooftop garden",
+        options: {
+          n: 2,
+          aspectRatio: "16:9",
+          aspect_ratio: "4:3",
+          resolution: "1k",
+          responseFormat: "url",
+          response_format: "b64_json",
+          future_option: true,
+        },
+        http: {
+          body: { resolution: "2k", future_option: "http" },
+          headers: { "x-request": "yes" },
+          query: { trace: "1" },
+        },
+      })
+
+      expect(response.images).toHaveLength(2)
+      expect(response.image?.mediaType).toBe("image/jpeg")
+      expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
+      expect(response.images[1]?.mediaType).toBe("application/octet-stream")
+      expect(response.images[1]?.data).toBe("https://api.xai.test/image.jpg")
+      expect(response.usage?.providerMetadata).toEqual({ xai: { num_images: 2 } })
+      expect(response.providerMetadata).toEqual({ xai: { usage: { num_images: 2 } } })
+    }).pipe(
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse((input) =>
+              Effect.gen(function* () {
+                const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
+                expect(request.url).toBe("https://api.xai.test/v1/images/generations?trace=1")
+                expect(request.headers.get("authorization")).toBe("Bearer test")
+                expect(request.headers.get("x-default")).toBe("yes")
+                expect(request.headers.get("x-request")).toBe("yes")
+                expect(JSON.parse(input.text)).toEqual({
+                  model: "grok-imagine-image",
+                  prompt: "A robot tending a rooftop garden",
+                  n: 2,
+                  aspect_ratio: "4:3",
+                  resolution: "2k",
+                  response_format: "b64_json",
+                  future_option: "http",
+                  configured: true,
+                })
+                return input.respond(
+                  JSON.stringify({
+                    data: [
+                      { b64_json: "AQID", url: null, mime_type: "image/jpeg" },
+                      { b64_json: null, url: "https://api.xai.test/image.jpg", mime_type: null },
+                    ],
+                    usage: { num_images: 2 },
+                  }),
+                  { headers: { "content-type": "application/json" } },
+                )
+              }),
+            ),
+          ),
+        ),
+      ),
+    ),
+  )
+
+  it.effect("supports request-level custom auth", () =>
+    Image.generate({
+      model: XAI.configure({
+        baseURL: "https://api.xai.test/v1",
+        auth: Auth.custom((input) =>
+          Effect.succeed(Headers.set(input.headers, "x-custom-auth", new URL(input.url).hostname)),
+        ),
+      }).image("grok-imagine-image"),
+      prompt: "A robot tending a rooftop garden",
+    }).pipe(
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse((input) =>
+              Effect.gen(function* () {
+                const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
+                expect(request.headers.get("x-custom-auth")).toBe("api.xai.test")
+                return input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
+                  headers: { "content-type": "application/json" },
+                })
+              }),
+            ),
+          ),
+        ),
+      ),
+    ),
+  )
+})

+ 32 - 0
packages/ai/test/provider/zai-images.recorded.test.ts

@@ -0,0 +1,32 @@
+import { describe, expect } from "bun:test"
+import { Effect } from "effect"
+import { Image } from "../../src"
+import { ZAI } from "../../src/providers"
+import { recordedTests } from "../recorded-test"
+
+const model = ZAI.configure({ apiKey: process.env.ZAI_API_KEY ?? "fixture" }).image("cogview-4-250304")
+
+const recorded = recordedTests({
+  prefix: "zai-images",
+  provider: "zai",
+  protocol: "zai-images",
+  requires: ["ZAI_API_KEY"],
+})
+
+describe("Z.ai Images recorded", () => {
+  recorded.effect("generates an image", () =>
+    Effect.gen(function* () {
+      const response = yield* Image.generate({
+        model,
+        prompt: "A simple flat red circle centered on a plain white background.",
+        options: { size: "1024x1024", quality: "standard", userID: "opencode-image-test" },
+      })
+
+      expect(response.images).toHaveLength(1)
+      expect(response.image?.mediaType).toBe("application/octet-stream")
+      expect(response.image?.data).toBeString()
+      expect(response.image?.data).toStartWith("https://")
+      expect(response.providerMetadata?.zai).toBeDefined()
+    }),
+  )
+})

+ 130 - 0
packages/ai/test/provider/zai-images.test.ts

@@ -0,0 +1,130 @@
+import { describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
+import { HttpClientRequest } from "effect/unstable/http"
+import { Image, ImageClient } from "../../src"
+import { ZAI } from "../../src/providers"
+import { it } from "../lib/effect"
+import { dynamicResponse, fixedResponse } from "../lib/http"
+
+describe("Z.ai Images", () => {
+  it.effect("generates through the Z.ai Images API", () =>
+    Effect.gen(function* () {
+      const response = yield* Image.generate({
+        model: ZAI.configure({
+          apiKey: "test",
+          baseURL: "https://api.z.ai.test/api/paas/v4",
+          headers: { "x-default": "yes" },
+          http: { body: { configured: true, quality: "configured" }, query: { trace: "default" } },
+        }).image("glm-image"),
+        prompt: "A red circle on a white background",
+        options: {
+          quality: "hd",
+          userID: "alias-user",
+          user_id: "raw-user",
+          future_option: true,
+        },
+        http: {
+          headers: { "x-request": "yes" },
+          query: { trace: "request" },
+          body: { quality: "final", user_id: "final-user" },
+        },
+      })
+
+      expect(response.images).toHaveLength(1)
+      expect(response.image?.mediaType).toBe("application/octet-stream")
+      expect(response.image?.data).toBe("https://cdn.z.ai/generated.png")
+      expect(response.providerMetadata).toEqual({
+        zai: {
+          created: 1_760_335_349,
+          id: "generation-1",
+          requestID: "request-1",
+          contentFilter: [{ role: "future-role", level: 4.5 }],
+        },
+      })
+    }).pipe(
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse((input) =>
+              Effect.gen(function* () {
+                const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
+                expect(request.url).toBe("https://api.z.ai.test/api/paas/v4/images/generations?trace=request")
+                expect(request.headers.get("authorization")).toBe("Bearer test")
+                expect(request.headers.get("x-default")).toBe("yes")
+                expect(request.headers.get("x-request")).toBe("yes")
+                expect(JSON.parse(input.text)).toEqual({
+                  model: "glm-image",
+                  prompt: "A red circle on a white background",
+                  quality: "final",
+                  user_id: "final-user",
+                  future_option: true,
+                  configured: true,
+                })
+                return input.respond(
+                  JSON.stringify({
+                    created: 1_760_335_349,
+                    id: "generation-1",
+                    request_id: "request-1",
+                    data: [{ url: "https://cdn.z.ai/generated.png" }],
+                    content_filter: [{ role: "future-role", level: 4.5 }],
+                  }),
+                  { headers: { "content-type": "application/json" } },
+                )
+              }),
+            ),
+          ),
+        ),
+      ),
+    ),
+  )
+
+  it.effect("lets raw native options override aliases", () =>
+    Image.generate({
+      model: ZAI.configure({ apiKey: "test" }).image("model"),
+      prompt: "test",
+      options: { quality: "future-quality", userID: "x", user_id: "raw-user" },
+    }).pipe(
+      Effect.provide(
+        ImageClient.layer.pipe(
+          Layer.provide(
+            dynamicResponse((input) => {
+              expect(JSON.parse(input.text)).toMatchObject({ quality: "future-quality", user_id: "raw-user" })
+              return Effect.succeed(
+                input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), {
+                  headers: { "content-type": "application/json" },
+                }),
+              )
+            }),
+          ),
+        ),
+      ),
+    ),
+  )
+
+  it.effect("rejects invalid response structures", () =>
+    Effect.gen(function* () {
+      const model = ZAI.configure({ apiKey: "test" }).image("model")
+      const payloads = [
+        {},
+        { data: [] },
+        { data: [{ b64_json: "image" }] },
+        { data: [{ url: 1 }] },
+        { data: [{ url: "https://example.test/image.jpg" }], content_filter: [{ role: 1, level: "high" }] },
+      ]
+
+      yield* Effect.forEach(payloads, (payload) =>
+        Image.generate({ model, prompt: "test" }).pipe(
+          Effect.provide(
+            ImageClient.layer.pipe(
+              Layer.provide(
+                fixedResponse(JSON.stringify(payload), { headers: { "content-type": "application/json" } }),
+              ),
+            ),
+          ),
+          Effect.flip,
+          Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidProviderOutput"))),
+        ),
+      )
+    }),
+  )
+})

+ 2 - 23
packages/ai/test/recorded-scenarios.ts

@@ -120,29 +120,8 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
     throw new Error("Weather tool loop exceeded 10 steps")
   })
 
-const assistantContent = (events: ReadonlyArray<LLMEvent>) => {
-  const content: ContentPart[] = []
-  for (const event of events) {
-    if (event.type === "text-delta" || event.type === "reasoning-delta") {
-      const type = event.type === "text-delta" ? "text" : "reasoning"
-      const last = content.at(-1)
-      if (last?.type === type) {
-        content[content.length - 1] = { ...last, text: `${last.text}${event.text}` }
-      } else {
-        content.push({ type, text: event.text })
-      }
-      continue
-    }
-    if (event.type === "text-end" || event.type === "reasoning-end") {
-      const type = event.type === "text-end" ? "text" : "reasoning"
-      const last = content.at(-1)
-      if (last?.type === type) content[content.length - 1] = { ...last, providerMetadata: event.providerMetadata }
-      continue
-    }
-    if (event.type === "tool-call") content.push(event)
-  }
-  return content
-}
+const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
+  events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
 
 export const expectFinish = (
   events: ReadonlyArray<LLMEvent>,

+ 27 - 0
packages/ai/test/tool-stream.test.ts

@@ -36,6 +36,33 @@ describe("ToolStream", () => {
     }),
   )
 
+  it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
+    Effect.gen(function* () {
+      const first = ToolStream.appendOrStart(
+        ADAPTER,
+        ToolStream.empty<number>(),
+        0,
+        { id: "call_1", name: "lookup", text: '{"query"' },
+        "missing tool",
+      )
+      if (ToolStream.isError(first)) return yield* first
+      const second = ToolStream.appendOrStart(
+        ADAPTER,
+        first.tools,
+        0,
+        { id: "", name: "", text: ':"weather"}' },
+        "missing tool",
+      )
+      if (ToolStream.isError(second)) return yield* second
+      const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0)
+
+      expect(finished.events).toEqual([
+        { type: "tool-input-end", id: "call_1", name: "lookup" },
+        { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
+      ])
+    }),
+  )
+
   it.effect("fails appendExisting when the provider skipped the tool start", () =>
     Effect.gen(function* () {
       const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")

+ 9 - 0
packages/ai/tsconfig.types.json

@@ -0,0 +1,9 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "noEmit": true,
+    "rootDir": "."
+  },
+  "include": ["test/**/*.types.ts"]
+}

+ 50 - 0
packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts

@@ -0,0 +1,50 @@
+import { expect, test } from "@playwright/test"
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectAppVisible } from "../utils/waits"
+
+const directory = "C:/OpenCode/PromptInputV2Editing"
+const projectID = "proj_prompt_input_v2_editing"
+const sessionID = "ses_prompt_input_v2_editing"
+
+test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: projectID,
+      worktree: directory,
+      vcs: "git",
+      name: "prompt-input-v2-editing",
+      time: { created: 1700000000000, updated: 1700000000000 },
+      sandboxes: [],
+    },
+    provider: { all: [], connected: [], default: {} },
+    sessions: [
+      {
+        id: sessionID,
+        slug: "prompt-input-v2-editing",
+        projectID,
+        directory,
+        title: "Prompt input V2 editing",
+        version: "dev",
+        time: { created: 1700000000000, updated: 1700000000000 },
+      },
+    ],
+    pageMessages: () => ({ items: [] }),
+  })
+  await page.addInitScript(() => {
+    localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+  })
+
+  await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+  const composer = page.locator('[data-component="prompt-input-v2"]')
+  const input = composer.locator('[data-component="prompt-input"]')
+  await expectAppVisible(composer)
+
+  await input.fill("keep me")
+  await composer.getByRole("button", { name: "Add images and files" }).click()
+  await page.getByRole("menuitem", { name: "Commands" }).click()
+  await page.locator('[data-suggestion-id="model.choose"]').click()
+
+  await expect(input).toHaveText("keep me")
+})

+ 3 - 6
packages/app/e2e/regression/prompt-thinking-level.spec.ts

@@ -54,18 +54,15 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
   })
 
   await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
-  const composer = page.locator('[data-component="session-composer"]')
+  const composer = page.locator('[data-component="prompt-input-v2"]')
   const input = composer.locator('[data-component="prompt-input"]')
-  const control = composer.locator('[data-component="prompt-variant-control"]')
+  const control = composer.getByRole("button", { name: "Choose model variant" })
   await expectAppVisible(composer)
 
   await idleComposer(page)
-  await expect(control).toBeHidden()
-
-  await composer.hover()
   await expect(control).toBeVisible()
 
-  await control.locator('[data-action="prompt-model-variant"]').click()
+  await control.click()
   const high = page.getByRole("menuitemradio", { name: "high" })
   await expect(high).toBeVisible()
   await page.mouse.move(0, 0)

+ 1 - 1
packages/app/e2e/smoke/session-timeline.spec.ts

@@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin
 }
 
 async function expectSessionReady(page: Page) {
-  await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
+  await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
 }

+ 2 - 2
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/app",
-  "version": "1.18.3",
+  "version": "1.18.4",
   "description": "",
   "type": "module",
   "exports": {
@@ -81,7 +81,7 @@
     "diff": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "catalog:",
-    "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
+    "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
     "luxon": "catalog:",
     "marked": "catalog:",
     "marked-shiki": "catalog:",

+ 8 - 0
packages/app/src/addons/serialize.test.ts

@@ -37,6 +37,14 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
 }
 
 describe("SerializeAddon", () => {
+  test("preserves color scheme reporting mode", async () => {
+    const { term, addon } = createTerminal()
+    await writeAndWait(term, "\x1b[?2031h")
+
+    expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true)
+    expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false)
+  })
+
   describe("ANSI color preservation", () => {
     test("should preserve text attributes (bold, italic, underline)", async () => {
       const { term, addon } = createTerminal()

+ 9 - 1
packages/app/src/addons/serialize.ts

@@ -89,6 +89,13 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined =
   return { active, normal, alternate }
 }
 
+const getTerminalMode = (value: ITerminalCore, mode: number) => {
+  if (!isRecord(value)) return false
+  const terminal = value.wasmTerm
+  if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false
+  return terminal.getMode(mode) === true
+}
+
 // ============================================================================
 // Types
 // ============================================================================
@@ -544,7 +551,8 @@ export class SerializeAddon implements ITerminalAddon {
       return ""
     }
 
-    let content = options?.range
+    let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : ""
+    content += options?.range
       ? this._serializeBufferByRange(normalBuffer, options.range, true)
       : this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
 

+ 11 - 1
packages/app/src/app.tsx

@@ -9,7 +9,16 @@ import { Font } from "@opencode-ai/ui/font"
 import { Splash } from "@opencode-ai/ui/logo"
 import { ThemeProvider } from "@opencode-ai/ui/theme/context"
 import { MetaProvider } from "@solidjs/meta"
-import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
+import {
+  type BaseRouterProps,
+  Navigate,
+  Route,
+  Router,
+  useLocation,
+  useNavigate,
+  useParams,
+  useSearchParams,
+} from "@solidjs/router"
 import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
 import { Effect } from "effect"
 import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -29,6 +38,7 @@ import {
   Show,
 } from "solid-js"
 import { Dynamic } from "solid-js/web"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
 import { CommentsProvider } from "@/context/comments"
 import { FileProvider } from "@/context/file"

+ 53 - 1
packages/app/src/components/debug-bar.tsx

@@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
 
 type Mem = Performance & {
   memory?: {
@@ -107,8 +108,45 @@ function Cell(props: {
   )
 }
 
+function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
+  const content = () => (
+    <button
+      type="button"
+      aria-label="Force focus styles on all interactive elements"
+      aria-pressed={props.active}
+      classList={{
+        "flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
+        "min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
+        "min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
+        "bg-surface-raised-base text-text-strong": props.active,
+      }}
+      onClick={props.onClick}
+    >
+      <span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
+      <span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
+        {props.active ? "ON" : "OFF"}
+      </span>
+    </button>
+  )
+
+  if (props.inline) {
+    return (
+      <TooltipV2 value="Force focus styles on all interactive elements" placement="top">
+        {content()}
+      </TooltipV2>
+    )
+  }
+
+  return (
+    <Tooltip value="Force focus styles on all interactive elements" placement="top">
+      {content()}
+    </Tooltip>
+  )
+}
+
 export function DebugBar(props: { inline?: boolean } = {}) {
   const language = useLanguage()
+  const platform = usePlatform()
   const location = useLocation()
   const routing = useIsRouting()
   const [state, setState] = createStore({
@@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
     delay: undefined as number | undefined,
     fps: undefined as number | undefined,
     gap: undefined as number | undefined,
+    focus: false,
     heap: {
       limit: undefined as number | undefined,
       used: undefined as number | undefined,
@@ -142,6 +181,16 @@ export function DebugBar(props: { inline?: boolean } = {}) {
   }
   const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
   const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
+  const toggleFocus = async () => {
+    if (!platform.setForceFocus) return
+    const enabled = !state.focus
+    await platform.setForceFocus(enabled)
+    setState("focus", enabled)
+  }
+
+  onCleanup(() => {
+    if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
+  })
 
   let prev = ""
   let start = 0
@@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
           bad={bad(heap(), 0.8)}
           dim={state.heap.used === undefined}
           inline={props.inline}
-          wide
+          wide={!platform.setForceFocus}
         />
+        {platform.setForceFocus && (
+          <FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
+        )}
       </div>
     </aside>
   )

+ 73 - 0
packages/app/src/components/dialog-connect-provider.stories.tsx

@@ -0,0 +1,73 @@
+// @ts-nocheck
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
+import { mockProviderAuth } from "@/context/server-sync"
+import { onCleanup, onMount } from "solid-js"
+import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
+
+function ConnectProviderDialogStory() {
+  const dialog = useDialog()
+  const open = () => dialog.show(() => <DialogConnectProvider />)
+
+  onMount(open)
+
+  return (
+    <Button variant="secondary" onClick={open}>
+      Open connect provider dialog
+    </Button>
+  )
+}
+
+function ProviderConnectionDialogStory(props) {
+  onCleanup(mockProviderAuth(props.provider, props.methods))
+  const dialog = useDialog()
+  const controller = useProviderConnectController()
+  controller.select(props.provider)
+  const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
+
+  onMount(open)
+
+  return (
+    <Button variant="secondary" onClick={open}>
+      Open {props.provider} connection dialog
+    </Button>
+  )
+}
+
+function renderConnection(provider, methods) {
+  return () => (
+    <QueryClientProvider client={new QueryClient()}>
+      <ProviderConnectionDialogStory provider={provider} methods={methods} />
+    </QueryClientProvider>
+  )
+}
+
+export default {
+  title: "App/Dialogs/Connect Provider",
+  id: "app-dialog-connect-provider",
+}
+
+export const V2 = {
+  render: () => (
+    <QueryClientProvider client={new QueryClient()}>
+      <ConnectProviderDialogStory />
+    </QueryClientProvider>
+  ),
+}
+
+export const ApiKey = {
+  render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
+}
+
+export const OpenCodeZen = {
+  render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
+}
+
+export const LoginMethods = {
+  render: renderConnection("openai", [
+    { type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
+    { type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
+    { type: "api", label: "API key" },
+  ]),
+}

+ 425 - 30
packages/app/src/components/dialog-connect-provider.tsx

@@ -9,6 +9,9 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { Tag } from "@opencode-ai/ui/tag"
 import { TextField } from "@opencode-ai/ui/text-field"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
+import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
 import { showToast } from "@/utils/toast"
 import {
   type Accessor,
@@ -16,6 +19,8 @@ import {
   createEffect,
   createMemo,
   createResource,
+  createUniqueId,
+  For,
   Match,
   onCleanup,
   onMount,
@@ -27,6 +32,7 @@ import { Link } from "@/components/link"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
+import { useSettings } from "@/context/settings"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { CustomProviderForm } from "./dialog-custom-provider"
 
@@ -50,32 +56,22 @@ export const DialogConnectProvider: Component<{
   const fallback = useProviderConnectController()
   const controller = props.controller ?? fallback
   const language = useLanguage()
+  const settings = useSettings()
+  const newLayout = settings.general.newLayoutDesigns
   const reset = controller.back
   const back = { current: reset }
+  let focusHost: HTMLDivElement | undefined
+  const holdFocus = () => focusHost?.focus({ preventScroll: true })
   const select = (provider?: string) => {
     back.current = reset
     controller.select(provider)
   }
 
-  return (
-    <Dialog
-      class="h-full"
-      transition
-      title={
-        <Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
-          <IconButton
-            tabIndex={-1}
-            icon="arrow-left"
-            variant="ghost"
-            onClick={() => back.current()}
-            aria-label={language.t("common.goBack")}
-          />
-        </Show>
-      }
-    >
+  function Content() {
+    return (
       <Switch>
         <Match when={controller.selected() === CUSTOM_ID}>
-          <CustomProviderForm />
+          <CustomProviderForm autofocus={!newLayout()} />
         </Match>
         <Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
           {(provider) => (
@@ -88,14 +84,76 @@ export const DialogConnectProvider: Component<{
           )}
         </Match>
         <Match when={true}>
-          <ProviderPicker directory={props.directory} onSelect={select} />
+          <ProviderPicker
+            directory={props.directory}
+            onSelect={select}
+            onPrepare={newLayout() ? holdFocus : undefined}
+          />
         </Match>
       </Switch>
-    </Dialog>
+    )
+  }
+
+  return (
+    <Show
+      when={newLayout()}
+      fallback={
+        <Dialog
+          class="h-full"
+          transition
+          title={
+            <Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
+              <IconButton
+                tabIndex={-1}
+                icon="arrow-left"
+                variant="ghost"
+                onClick={() => back.current()}
+                aria-label={language.t("common.goBack")}
+              />
+            </Show>
+          }
+        >
+          <Content />
+        </Dialog>
+      }
+    >
+      <DialogV2
+        containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
+        class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
+      >
+        <DialogHeader closeLabel={language.t("common.close")}>
+          <Show
+            when={controller.selected()}
+            fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
+          >
+            <button
+              type="button"
+              class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+              onClick={() => back.current()}
+              aria-label={language.t("common.goBack")}
+            >
+              <Icon name="arrow-left" size="small" />
+            </button>
+          </Show>
+        </DialogHeader>
+        <DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
+          <div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
+            <Content />
+          </div>
+        </DialogBody>
+      </DialogV2>
+    </Show>
   )
 }
 
-function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
+function ProviderPicker(props: {
+  directory?: Accessor<string | undefined>
+  onSelect: (provider: string) => void
+  onPrepare?: () => void
+}) {
+  const settings = useSettings()
+  if (settings.general.newLayoutDesigns())
+    return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
   const providers = useProviders(props.directory)
   const language = useLanguage()
   const popularGroup = () => language.t("dialog.provider.group.popular")
@@ -163,6 +221,171 @@ function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSel
   )
 }
 
+function ProviderPickerV2(props: {
+  directory?: Accessor<string | undefined>
+  onSelect: (provider: string) => void
+  onPrepare?: () => void
+}) {
+  const providers = useProviders(props.directory)
+  const language = useLanguage()
+  const serverSync = useServerSync()
+  const serverSDK = useServerSDK()
+  const [store, setStore] = createStore({
+    filter: "",
+    active: undefined as string | undefined,
+    connecting: undefined as string | undefined,
+  })
+  const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
+  const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
+  const all = createMemo(() => {
+    language.locale()
+    const query = store.filter.trim().toLowerCase()
+    const values = [custom(), ...providers.all().values()]
+    if (!query) return values
+    return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
+  })
+  const popular = createMemo(() =>
+    all()
+      .filter((provider) => featured.includes(provider.id))
+      .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
+  )
+  const other = createMemo(() =>
+    all()
+      .filter((provider) => !featured.includes(provider.id))
+      .sort((a, b) => {
+        if (a.id === CUSTOM_ID) return -1
+        if (b.id === CUSTOM_ID) return 1
+        return a.name.localeCompare(b.name)
+      }),
+  )
+  const rows = createMemo(() => [...popular(), ...other()])
+  let picker: HTMLDivElement | undefined
+  let search: HTMLInputElement | undefined
+
+  onMount(() => search?.focus({ preventScroll: true }))
+
+  const connect = (provider: string) => {
+    props.onPrepare?.()
+    if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
+      props.onSelect(provider)
+      return
+    }
+    if (store.connecting) return
+    setStore("connecting", provider)
+    void serverSDK()
+      .client.provider.auth()
+      .then((response) => {
+        serverSync().set("provider_auth", response.data ?? {})
+        props.onSelect(provider)
+      })
+      .catch(() => props.onSelect(provider))
+  }
+
+  const move = (event: KeyboardEvent, direction: number) => {
+    const items = rows()
+    if (items.length === 0) return
+    const index = items.findIndex((provider) => provider.id === store.active)
+    const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
+    setStore("active", items[next].id)
+    picker
+      ?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
+      ?.focus({ preventScroll: true })
+    event.preventDefault()
+  }
+
+  const handleKeyDown = (event: KeyboardEvent) => {
+    if (event.key === "ArrowDown") return move(event, 1)
+    if (event.key === "ArrowUp") return move(event, -1)
+    if (event.key !== "Enter" || !store.active) return
+    connect(store.active)
+    event.preventDefault()
+  }
+
+  return (
+    <div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
+      <div class="shrink-0 px-1 pt-px">
+        <TextInputV2
+          ref={search}
+          type="search"
+          class="!w-full [font-family:var(--v2-font-family-sans)]"
+          leadingIcon={<Icon name="magnifying-glass" size="small" />}
+          placeholder={language.t("dialog.provider.search.placeholder")}
+          value={store.filter}
+          onInput={(event) => {
+            setStore({ filter: event.currentTarget.value, active: undefined })
+          }}
+        />
+      </div>
+      <div class="relative min-h-0 flex-1">
+        <div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
+          <For
+            each={[
+              { title: language.t("dialog.provider.group.popular"), items: popular },
+              { title: language.t("dialog.provider.group.other"), items: other },
+            ]}
+          >
+            {(group) => (
+              <Show when={group.items().length > 0}>
+                <section class="flex flex-col">
+                  <div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
+                    {group.title}
+                  </div>
+                  <For each={group.items()}>
+                    {(provider) => (
+                      <button
+                        type="button"
+                        data-provider-id={provider.id}
+                        class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
+                        classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
+                        onMouseEnter={() => setStore("active", provider.id)}
+                        disabled={store.connecting !== undefined}
+                        aria-busy={store.connecting === provider.id}
+                        onClick={() => connect(provider.id)}
+                      >
+                        <ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
+                        <span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
+                        <Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
+                          <span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
+                            {language.t(
+                              provider.id === "opencode"
+                                ? "dialog.provider.opencode.tagline"
+                                : "dialog.provider.opencodeGo.tagline",
+                            )}
+                          </span>
+                          <span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
+                            {language.t("dialog.provider.tag.recommended")}
+                          </span>
+                        </Show>
+                        <Show when={provider.id === CUSTOM_ID}>
+                          <span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
+                            {language.t("settings.providers.tag.custom")}
+                          </span>
+                        </Show>
+                        <Show when={store.connecting === provider.id}>
+                          <Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
+                        </Show>
+                      </button>
+                    )}
+                  </For>
+                </section>
+              </Show>
+            )}
+          </For>
+          <Show when={rows().length === 0}>
+            <div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
+              {language.t("dialog.provider.empty")}
+            </div>
+          </Show>
+        </div>
+        <div
+          class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
+          style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
+        />
+      </div>
+    </div>
+  )
+}
+
 function ProviderConnection(props: {
   provider: string
   directory?: Accessor<string | undefined>
@@ -173,6 +396,8 @@ function ProviderConnection(props: {
   const serverSync = useServerSync()
   const serverSDK = useServerSDK()
   const language = useLanguage()
+  const settings = useSettings()
+  const newLayout = settings.general.newLayoutDesigns
   const providers = useProviders(props.directory)
 
   const alive = { value: true }
@@ -207,11 +432,19 @@ function ProviderConnection(props: {
   )
   const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
   const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
+  const cachedMethods = serverSync().data.provider_auth[props.provider]
+  const directMethod =
+    cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
   const [store, setStore] = createStore({
-    methodIndex: undefined as undefined | number,
+    methodIndex: directMethod as undefined | number,
     authorization: undefined as undefined | ProviderAuthAuthorization,
     promptInputs: undefined as undefined | Record<string, string>,
-    state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
+    state: (directMethod === undefined ? "pending" : undefined) as
+      | undefined
+      | "pending"
+      | "complete"
+      | "error"
+      | "prompt",
     error: undefined as string | undefined,
   })
 
@@ -279,6 +512,16 @@ function ProviderConnection(props: {
     return value.label ?? ""
   }
 
+  const methodDetails = (value?: { type?: string; label?: string }) => {
+    const label = methodLabel(value)
+    const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
+    const hint = suffix?.[1]
+    return {
+      label: suffix ? label.slice(0, -suffix[0].length) : label,
+      hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
+    }
+  }
+
   function formatError(value: unknown, fallback: string): string {
     if (value && typeof value === "object" && "data" in value) {
       const data = (value as { data?: { message?: unknown } }).data
@@ -519,6 +762,37 @@ function ProviderConnection(props: {
   props.setBack(goBack)
 
   function MethodSelection() {
+    if (newLayout())
+      return (
+        <div class="flex flex-col gap-2">
+          <div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
+            {language.t("provider.connect.selectMethod", { provider: provider().name })}
+          </div>
+          <div class="flex flex-col">
+            <For each={methods()}>
+              {(item, index) => {
+                const details = () => methodDetails(item)
+                return (
+                  <button
+                    type="button"
+                    class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+                    onClick={() => void selectMethod(index())}
+                  >
+                    <span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
+                      <span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
+                    </span>
+                    <span class="font-[530] text-v2-text-text-base">{details().label}</span>
+                    <Show when={details().hint}>
+                      {(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
+                    </Show>
+                  </button>
+                )
+              }}
+            </For>
+          </div>
+        </div>
+      )
+
     return (
       <>
         <div class="text-14-regular text-text-base">
@@ -552,11 +826,18 @@ function ProviderConnection(props: {
   }
 
   function ApiAuthView() {
+    let apiKey: HTMLInputElement | undefined
+    const errorID = createUniqueId()
     const [formStore, setFormStore] = createStore({
       value: "",
       error: undefined as string | undefined,
     })
 
+    onMount(() => {
+      if (!newLayout()) return
+      apiKey?.focus({ preventScroll: true })
+    })
+
     async function handleSubmit(e: SubmitEvent) {
       e.preventDefault()
 
@@ -581,6 +862,58 @@ function ProviderConnection(props: {
       await complete()
     }
 
+    if (newLayout())
+      return (
+        <div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
+          <Show
+            when={provider().id === "opencode"}
+            fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
+          >
+            <div class="flex flex-col gap-5">
+              <div>{language.t("provider.connect.opencodeZen.line1")}</div>
+              <div>{language.t("provider.connect.opencodeZen.line2")}</div>
+              <div>
+                {language.t("provider.connect.opencodeZen.visit.prefix")}
+                <Link
+                  href="https://opencode.ai/zen"
+                  class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
+                >
+                  {language.t("provider.connect.opencodeZen.visit.link")}
+                </Link>
+                {language.t("provider.connect.opencodeZen.visit.suffix")}
+              </div>
+            </div>
+          </Show>
+          <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
+            <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
+              {language.t("provider.connect.apiKey.label", { provider: provider().name })}
+              <TextInputV2
+                ref={apiKey}
+                class="!w-full"
+                name="apiKey"
+                placeholder={language.t("provider.connect.apiKey.placeholder")}
+                value={formStore.value}
+                invalid={formStore.error !== undefined}
+                aria-describedby={formStore.error ? errorID : undefined}
+                autocomplete="off"
+                spellcheck={false}
+                onInput={(event) => setFormStore("value", event.currentTarget.value)}
+              />
+            </label>
+            <Show when={formStore.error}>
+              {(error) => (
+                <div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
+                  {error()}
+                </div>
+              )}
+            </Show>
+            <ButtonV2 type="submit" variant="contrast">
+              {language.t("common.continue")}
+            </ButtonV2>
+          </form>
+        </div>
+      )
+
     return (
       <div class="flex flex-col gap-6">
         <Switch>
@@ -605,7 +938,8 @@ function ProviderConnection(props: {
         </Switch>
         <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
           <TextField
-            autofocus
+            autofocus={!newLayout()}
+            ref={apiKey}
             type="text"
             label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
             placeholder={language.t("provider.connect.apiKey.placeholder")}
@@ -624,11 +958,18 @@ function ProviderConnection(props: {
   }
 
   function OAuthCodeView() {
+    let codeInput: HTMLInputElement | undefined
+    const errorID = createUniqueId()
     const [formStore, setFormStore] = createStore({
       value: "",
       error: undefined as string | undefined,
     })
 
+    onMount(() => {
+      if (!newLayout()) return
+      codeInput?.focus({ preventScroll: true })
+    })
+
     async function handleSubmit(e: SubmitEvent) {
       e.preventDefault()
 
@@ -657,6 +998,46 @@ function ProviderConnection(props: {
       setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
     }
 
+    if (newLayout())
+      return (
+        <div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
+          <div>
+            {language.t("provider.connect.oauth.code.visit.prefix")}
+            <Link href={store.authorization!.url} class="text-v2-text-text-base">
+              {language.t("provider.connect.oauth.code.visit.link")}
+            </Link>
+            {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
+          </div>
+          <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
+            <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
+              {language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
+              <TextInputV2
+                ref={codeInput}
+                class="!w-full"
+                name="code"
+                placeholder={language.t("provider.connect.oauth.code.placeholder")}
+                value={formStore.value}
+                invalid={formStore.error !== undefined}
+                aria-describedby={formStore.error ? errorID : undefined}
+                autocomplete="off"
+                spellcheck={false}
+                onInput={(event) => setFormStore("value", event.currentTarget.value)}
+              />
+            </label>
+            <Show when={formStore.error}>
+              {(error) => (
+                <div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
+                  {error()}
+                </div>
+              )}
+            </Show>
+            <ButtonV2 type="submit" variant="contrast">
+              {language.t("common.continue")}
+            </ButtonV2>
+          </form>
+        </div>
+      )
+
     return (
       <div class="flex flex-col gap-6">
         <div class="text-14-regular text-text-base">
@@ -666,7 +1047,8 @@ function ProviderConnection(props: {
         </div>
         <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
           <TextField
-            autofocus
+            autofocus={!newLayout()}
+            ref={codeInput}
             type="text"
             label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
             placeholder={language.t("provider.connect.oauth.code.placeholder")}
@@ -738,10 +1120,19 @@ function ProviderConnection(props: {
   }
 
   return (
-    <div class="flex flex-col gap-6 px-2.5 pb-3">
-      <div class="px-2.5 flex gap-4 items-center">
-        <ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
-        <div class="text-16-medium text-text-strong">
+    <div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
+      <div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}>
+        <ProviderIcon
+          id={props.provider}
+          class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
+        />
+        <div
+          class={
+            newLayout()
+              ? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
+              : "text-16-medium text-text-strong"
+          }
+        >
           <Switch>
             <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
               {language.t("provider.connect.title.anthropicProMax")}
@@ -750,8 +1141,12 @@ function ProviderConnection(props: {
           </Switch>
         </div>
       </div>
-      <div class="px-2.5 pb-10 flex flex-col gap-6">
-        <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
+      <div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}>
+        <div
+          onKeyDown={handleKey}
+          tabIndex={newLayout() ? undefined : 0}
+          autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
+        >
           <Switch>
             <Match when={loading()}>
               <div class="text-14-regular text-text-base">

+ 2 - 2
packages/app/src/components/dialog-custom-provider.tsx

@@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
   )
 }
 
-export function CustomProviderForm() {
+export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
   const dialog = useDialog()
   const serverSync = useServerSync()
   const serverSDK = useServerSDK()
@@ -192,7 +192,7 @@ export function CustomProviderForm() {
 
         <div class="flex flex-col gap-4">
           <TextField
-            autofocus
+            autofocus={props.autofocus ?? true}
             label={language.t("provider.custom.field.providerID.label")}
             placeholder={language.t("provider.custom.field.providerID.placeholder")}
             description={language.t("provider.custom.field.providerID.description")}

+ 55 - 0
packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx

@@ -0,0 +1,55 @@
+// @ts-nocheck
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { createSignal, onMount } from "solid-js"
+import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
+
+const names = [
+  "MiMo V2.5 Free",
+  "Nemotron 3 Ultra Free",
+  "Deepseek V4 Flash Free",
+  "North Mini Code Free",
+  "Hy3 Free",
+  "Big Pickle",
+]
+
+function SelectModelWithoutProviders() {
+  const dialog = useDialog()
+  const models = names.map((name, index) => ({
+    id: name.toLowerCase().replaceAll(" ", "-"),
+    name,
+    provider: { id: "opencode", name: "OpenCode" },
+    cost: { input: 0, output: 0 },
+    limit: { context: 128_000 },
+    capabilities: {
+      reasoning: index !== 5,
+      input: { text: true, image: false, audio: false, video: false, pdf: false },
+    },
+  }))
+  const [current, setCurrent] = createSignal(models[2])
+  const model = {
+    list: () => models,
+    current,
+    set(value) {
+      setCurrent(models.find((item) => item.id === value?.modelID))
+    },
+  }
+  const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
+
+  onMount(open)
+
+  return (
+    <Button variant="secondary" onClick={open}>
+      Open select model dialog
+    </Button>
+  )
+}
+
+export default {
+  title: "App/Dialogs/Select Model",
+  id: "app-dialog-select-model",
+}
+
+export const WithoutProviders = {
+  render: () => <SelectModelWithoutProviders />,
+}

+ 96 - 94
packages/app/src/components/dialog-select-model-unpaid-v2.tsx

@@ -1,23 +1,26 @@
 import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
 import { Icon } from "@opencode-ai/ui/v2/icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import { Tag } from "@opencode-ai/ui/v2/badge-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useTheme } from "@opencode-ai/ui/theme"
 import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
 import { useLocal } from "@/context/local"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
+import { useProviders } from "@/hooks/use-providers"
 import { decode64 } from "@/utils/base64"
 import { useLanguage } from "@/context/language"
 import { ModelTooltip } from "./model-tooltip"
 
 type ModelState = ReturnType<typeof useLocal>["model"]
+const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
+const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
 
 export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
   const local = useLocal()
   const model = props.model ?? local.model
   const dialog = useDialog()
+  const theme = useTheme()
   const directory = () => decode64(local.slug())
   const providers = useProviders(directory)
   const language = useLanguage()
@@ -28,6 +31,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
   })
   const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
     item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
+  const freeModels = createMemo(() => model.list().filter(isFree))
 
   const openProviders = (provider?: string) => {
     void import("./dialog-connect-provider").then((x) => {
@@ -62,111 +66,109 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
   })
 
   return (
-    <DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
+    <DialogV2
+      fit
+      containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
+      class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
+    >
       <DialogHeader closeLabel={language.t("common.close")}>
         <DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
       </DialogHeader>
-      <div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
-      <DialogBody class="min-h-0 flex-1 gap-0">
-        <ScrollView class="min-h-0 flex-1 w-full">
-          <div ref={listEl} class="flex min-h-full flex-col">
-            <div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
-              <div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
-                <div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
-                  {language.t("dialog.model.unpaid.freeModels.title")}
-                </div>
+      <DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
+        <div ref={listEl} class="flex min-h-0 flex-col">
+          <div class="flex w-full flex-col items-start pb-3">
+            <div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
+              <div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
+                {language.t("dialog.model.unpaid.freeModels.title")}
               </div>
-              <For each={model.list()}>
-                {(item) => (
-                  <TooltipV2
-                    class="w-full"
-                    placement="right-start"
-                    gutter={6}
-                    openDelay={0}
-                    value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
-                  >
-                    <button
-                      type="button"
-                      class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
-                      onClick={() => selectModel(item)}
-                    >
-                      <span class="min-w-0 truncate">{item.name}</span>
-                      <Show when={isFree(item)}>
-                        <Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
-                      </Show>
-                      <Show when={item.latest}>
-                        <Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
-                      </Show>
-                      <Show when={currentKey() === modelKey(item)}>
-                        <Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
-                      </Show>
-                    </button>
-                  </TooltipV2>
-                )}
-              </For>
             </div>
-
-            <div class="flex w-full flex-col p-2.5 pt-0">
-              <div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
-                <div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
-                  <div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
-                    {language.t("dialog.model.unpaid.addMore.title")}
-                  </div>
-                </div>
-                <div class="flex w-full flex-col">
-                  <For
-                    each={[...providers.popular()].sort((a, b) => {
-                      if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
-                        return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
-                      }
-                      return a.name.localeCompare(b.name)
-                    })}
-                  >
-                    {(provider) => (
-                      <button
-                        type="button"
-                        class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
-                        onClick={() => openProviders(provider.id)}
-                      >
-                        <ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
-                        <span class="min-w-0 truncate">{provider.name}</span>
-                        <Show when={provider.id === "opencode"}>
-                          <span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
-                            {language.t("dialog.provider.opencode.tagline")}
-                          </span>
-                          <Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
-                        </Show>
-                        <Show when={provider.id === "opencode-go"}>
-                          <span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
-                            {language.t("dialog.provider.opencodeGo.tagline")}
-                          </span>
-                          <Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
-                        </Show>
-                        <Show when={provider.id === "anthropic"}>
-                          <span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
-                            {language.t("dialog.provider.anthropic.note")}
-                          </span>
-                        </Show>
-                      </button>
-                    )}
-                  </For>
+            <For each={freeModels()}>
+              {(item) => (
+                <TooltipV2
+                  class="w-full"
+                  placement="right-start"
+                  gutter={6}
+                  openDelay={0}
+                  contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
+                  value={
+                    <ModelTooltip
+                      model={{ ...item, name: displayModelName(item.name) }}
+                      latest={item.latest}
+                      free={isFree(item)}
+                      v2
+                    />
+                  }
+                >
                   <button
                     type="button"
-                    class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
-                    onClick={() => openProviders()}
+                    class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
+                    onClick={() => selectModel(item)}
                   >
-                    <span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
-                      <Icon name="dot-grid" size="small" />
-                    </span>
-                    <span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
-                      {language.t("dialog.provider.viewAll")}
-                    </span>
+                    <span class="min-w-0 truncate">{displayModelName(item.name)}</span>
+                    <Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
+                    <Show when={item.latest}>
+                      <Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
+                    </Show>
+                    <Show when={currentKey() === modelKey(item)}>
+                      <Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
+                    </Show>
                   </button>
+                </TooltipV2>
+              )}
+            </For>
+          </div>
+
+          <div class="flex w-full flex-col">
+            <div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
+              <div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
+                <div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
+                  {language.t("dialog.model.unpaid.addMore.title")}
                 </div>
               </div>
+              <div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
+                <For
+                  each={[...providers.popular()]
+                    .filter((provider) => featuredProviders.includes(provider.id))
+                    .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
+                >
+                  {(provider) => (
+                    <button
+                      type="button"
+                      class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
+                      classList={{
+                        "border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
+                          theme.mode() !== "dark",
+                        "border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
+                      }}
+                      onClick={() => openProviders(provider.id)}
+                    >
+                      <ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
+                      <span class="flex min-w-0 flex-col">
+                        <span class="truncate">{provider.name}</span>
+                        <Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
+                          <span class="truncate font-[440] text-v2-text-text-muted">
+                            {language.t(
+                              provider.id === "opencode"
+                                ? "dialog.provider.opencode.tagline"
+                                : "dialog.provider.opencodeGo.tagline",
+                            )}
+                          </span>
+                        </Show>
+                      </span>
+                    </button>
+                  )}
+                </For>
+                <button
+                  type="button"
+                  class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
+                  onClick={() => openProviders()}
+                >
+                  {language.t("dialog.model.unpaid.viewMoreProviders")}
+                </button>
+              </div>
             </div>
           </div>
-        </ScrollView>
+        </div>
       </DialogBody>
     </DialogV2>
   )

+ 32 - 3
packages/app/src/components/help-button.tsx

@@ -16,6 +16,7 @@ export function TabsInfoPopup() {
   const settings = useSettings()
   const platform = usePlatform()
   const [drawerOpen, setDrawerOpen] = createSignal(false)
+  const windows = () => platform.platform === "desktop" && platform.os === "windows"
 
   return (
     <Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
@@ -70,12 +71,40 @@ export function TabsInfoPopup() {
           </button>
         </div>
       </Show>
-      <DrawerContent>
-        <div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
+      <DrawerContent
+        style={
+          windows()
+            ? {
+                inset: "0 0 0 auto",
+                "max-height": "100vh",
+                "max-width": "100vw",
+                "border-radius": "0",
+              }
+            : undefined
+        }
+      >
+        <Show when={windows()}>
+          <DrawerClose
+            as={IconButtonV2}
+            type="button"
+            size="small"
+            variant="neutral"
+            aria-label="Close"
+            icon={<IconV2 name="xmark-small" />}
+            class="absolute top-[10px] left-[-36px]"
+          />
+        </Show>
+        <div
+          class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
+          classList={{
+            "h-[40px] px-4": windows(),
+            "h-[52px] p-4": !windows(),
+          }}
+        >
           <p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
             July 14
           </p>
-          <Show when={platform.platform !== "desktop" || platform.os !== "windows"}>
+          <Show when={!windows()}>
             <DrawerClose
               as={IconButtonV2}
               type="button"

+ 591 - 0
packages/app/src/components/prompt-input-v2.tsx

@@ -0,0 +1,591 @@
+import { ImagePreview } from "@opencode-ai/ui/image-preview"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Icon } from "@opencode-ai/ui/v2/icon"
+import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
+import { createEffect, createMemo, on, Show } from "solid-js"
+import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
+import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
+import type { PromptInputProps } from "@/components/prompt-input/contracts"
+import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
+import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
+import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
+import { createPromptSubmit } from "@/components/prompt-input/submit"
+import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
+import { useComments } from "@/context/comments"
+import { useCommand } from "@/context/command"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { usePermission } from "@/context/permission"
+import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
+import { usePlatform } from "@/context/platform"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+import { createSessionTabs } from "@/pages/session/helpers"
+import { showToast } from "@/utils/toast"
+import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
+import {
+  createPromptInputV2Controller,
+  createPromptInputV2State,
+  type PromptInputV2Interaction,
+} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
+
+export type PromptInputV2ComposerProps = {
+  class?: string
+  controller: PromptInputV2ComposerController
+  borderUnderlay?: boolean
+  edit?: PromptInputProps["edit"]
+  onEditLoaded?: PromptInputProps["onEditLoaded"]
+}
+
+export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
+export type PromptInputV2ComposerController = PromptInputV2Interaction & {
+  readonly model: PromptInputProps["controls"]["model"]
+}
+
+export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
+  const dialog = useDialog()
+  const command = useCommand()
+  const language = useLanguage()
+
+  useCommands(props)
+  useEditHandler(props)
+
+  return (
+    <div class="flex flex-col gap-3">
+      <PromptInputV2
+        controller={props.controller}
+        borderUnderlay={props.borderUnderlay}
+        class={props.class}
+        attachKeybind={command.keybindParts("file.attach")}
+        attachShortcut={command.keybind("file.attach")}
+        modelControl={
+          <PromptInputV2ModelControl
+            loading={props.controller.model.loading}
+            paid={props.controller.model.paid}
+            title={language.t("command.model.choose")}
+            keybind={command.keybindParts("model.choose")}
+            model={props.controller.model.selection}
+            providerID={props.controller.model.selection.current()?.provider?.id}
+            modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
+            onClose={props.controller.restoreFocus}
+            onUnpaidClick={() =>
+              dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
+            }
+          />
+        }
+      />
+    </div>
+  )
+}
+
+const useEditHandler = (props: PromptInputV2ComposerProps) => {
+  const prompt = usePrompt()
+
+  createEffect(
+    on(
+      () => props.edit?.id,
+      (id) => {
+        const edit = props.edit
+        if (!id || !edit) return
+        prompt.context.items().forEach((item) => prompt.context.remove(item.key))
+        edit.context.forEach((item) =>
+          prompt.context.add({
+            type: item.type,
+            path: item.path,
+            selection: item.selection,
+            comment: item.comment,
+            commentID: item.commentID,
+            commentOrigin: item.commentOrigin,
+            preview: item.preview,
+          }),
+        )
+        props.controller.dispatch({ type: "mode.normal" })
+        props.controller.resetHistory()
+        prompt.set(edit.prompt, promptLength(edit.prompt))
+        props.controller.restoreFocus()
+        props.onEditLoaded?.()
+      },
+      { defer: true },
+    ),
+  )
+}
+
+const useCommands = (props: PromptInputV2ComposerProps) => {
+  const command = useCommand()
+  const language = useLanguage()
+
+  command.register("prompt-input", () => [
+    {
+      id: "file.attach",
+      title: language.t("prompt.action.attachFile"),
+      category: language.t("command.category.file"),
+      keybind: "mod+u",
+      disabled: props.controller.state.mode !== "normal",
+      onSelect: () => props.controller.attach(),
+    },
+    {
+      id: "prompt.mode.shell",
+      title: language.t("command.prompt.mode.shell"),
+      category: language.t("command.category.session"),
+      keybind: "mod+shift+x",
+      disabled: props.controller.state.mode === "shell",
+      onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
+    },
+    {
+      id: "prompt.mode.normal",
+      title: language.t("command.prompt.mode.normal"),
+      category: language.t("command.category.session"),
+      keybind: "mod+shift+e",
+      disabled: props.controller.state.mode === "normal",
+      onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
+    },
+  ])
+}
+
+export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
+  const sdk = useSDK()
+  const sync = useSync()
+  const files = useFile()
+  const layout = useLayout()
+  const comments = useComments()
+  const dialog = useDialog()
+  const command = useCommand()
+  const permission = usePermission()
+  const language = useLanguage()
+  const platform = usePlatform()
+  const prompt = props.state ?? usePrompt()
+  let editor: HTMLDivElement | undefined
+
+  const interaction = createPromptInputV2State()
+  const mode = () => interaction[0].mode
+  const history = props.history ?? createPersistedPromptInputHistory()
+  const tabs = () => props.controls.session.tabs
+  const activeFileTab = createSessionTabs({
+    tabs,
+    pathFromTab: files.pathFromTab,
+    normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
+  }).activeFileTab
+  const recent = createMemo(() => {
+    const all = tabs().all()
+    const active = activeFileTab()
+    const order = active ? [active, ...all.filter((tab) => tab !== active)] : all
+    return order.reduce<string[]>((result, tab) => {
+      const path = files.pathFromTab(tab)
+      if (!path || result.includes(path)) return result
+      return [...result, path]
+    }, [])
+  })
+  const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
+  const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
+  const attachments = createMemo(() =>
+    prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
+  )
+  const commentCount = createMemo(() => {
+    if (mode() === "shell") return 0
+    return prompt.context.items().filter((item) => !!item.comment?.trim()).length
+  })
+  const blank = createMemo(() => {
+    const text = prompt
+      .current()
+      .map((part) => ("content" in part ? part.content : ""))
+      .join("")
+    return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
+  })
+  const stopping = createMemo(() => working() && blank())
+  const placeholder = createMemo(() =>
+    promptPlaceholder({
+      mode: mode(),
+      commentCount: commentCount(),
+      example: mode() === "shell" ? "git status" : "",
+      suggest: false,
+      t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
+    }),
+  )
+  const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
+
+  const historyComments = () => {
+    const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
+    return prompt.context.items().flatMap((item) => {
+      const comment = item.comment?.trim()
+      if (!comment) return []
+      const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
+      const nextSelection =
+        selection ??
+        (item.selection
+          ? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
+          : undefined)
+      if (!nextSelection) return []
+      return [
+        {
+          id: item.commentID ?? item.key,
+          path: item.path,
+          selection: { ...nextSelection },
+          comment,
+          time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
+          origin: item.commentOrigin,
+          preview: item.preview,
+        } satisfies PromptHistoryComment,
+      ]
+    })
+  }
+  const restoreHistoryComments = (items: PromptHistoryComment[]) => {
+    comments.replace(
+      items.map((item) => ({
+        id: item.id,
+        file: item.path,
+        selection: { ...item.selection },
+        comment: item.comment,
+        time: item.time,
+      })),
+    )
+    prompt.context.replaceComments(
+      items.map((item) => ({
+        type: "file",
+        path: item.path,
+        selection: selectionFromLines(item.selection),
+        comment: item.comment,
+        commentID: item.id,
+        commentOrigin: item.origin,
+        preview: item.preview,
+      })),
+    )
+  }
+
+  const accepting = createMemo(() => {
+    const id = props.controls.session.id
+    if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
+    return permission.isAutoAccepting(id, sdk().directory)
+  })
+  const submission = createPromptSubmit({
+    prompt,
+    info,
+    imageAttachments: attachments,
+    commentCount,
+    autoAccept: accepting,
+    mode,
+    working,
+    editor: () => editor,
+    queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
+    promptLength,
+    addToHistory: (value, mode) => controller.addHistory(value, mode),
+    resetHistoryNavigation: () => controller.resetHistory(),
+    setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
+    setPopover: (popover) => {
+      if (!popover) controller.dispatch({ type: "popover.close" })
+    },
+    newSessionWorktree: () => props.newSessionWorktree,
+    onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
+    shouldQueue: props.shouldQueue,
+    onQueue: props.onQueue,
+    onAbort: props.onAbort,
+    onSubmit: props.onSubmit,
+    model: props.controls.model.selection,
+  })
+
+  const referenceDescription = (reference: ReferenceInfo) =>
+    reference.source.type === "git" ? reference.source.repository : reference.source.path
+  const references = createMemo(() =>
+    sync()
+      .data.reference.filter((reference) => !reference.hidden)
+      .map((reference) => ({
+        id: `reference:${reference.name}`,
+        kind: "reference" as const,
+        label: `@${reference.name}`,
+        path: reference.path,
+        description: reference.description ?? referenceDescription(reference),
+        mention: {
+          type: "file" as const,
+          path: reference.path,
+          content: `@${reference.name}`,
+          start: 0,
+          end: 0,
+          mime: "application/x-directory",
+          filename: reference.name,
+        },
+      })),
+  )
+  const resources = createMemo(() =>
+    Object.values(sync().data.mcp_resource).map((resource) => ({
+      id: `resource:${resource.client}:${resource.uri}`,
+      kind: "resource" as const,
+      label: `@${resource.name}`,
+      path: resource.uri,
+      description: resource.description,
+      mention: {
+        type: "file" as const,
+        path: resource.uri,
+        content: `@${resource.name}`,
+        start: 0,
+        end: 0,
+        mime: resource.mimeType ?? "text/plain",
+        filename: resource.name,
+        url: resource.uri,
+        source: {
+          type: "resource" as const,
+          text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
+          clientName: resource.client,
+          uri: resource.uri,
+        },
+      },
+      resource,
+    })),
+  )
+  const context = createMemo<PromptInputV2Suggestion[]>(() => [
+    ...references(),
+    ...props.controls.agents.available
+      .filter((agent) => !agent.hidden && agent.mode !== "primary")
+      .map((agent) => ({
+        id: `agent:${agent.name}`,
+        kind: "agent" as const,
+        label: `@${agent.name}`,
+        mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
+      })),
+    ...resources(),
+    ...recent().map((path) => ({
+      id: `file:${path}`,
+      kind: "file" as const,
+      label: path,
+      path,
+      recent: true,
+      mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
+    })),
+  ])
+  const slashCommands = createMemo(() => [
+    ...sync().data.command.map((item) => ({
+      id: `custom.${item.name}`,
+      trigger: item.name,
+      title: item.name,
+      description: item.description,
+      type: "custom" as const,
+    })),
+    ...command.options
+      .filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash)
+      .map((item) => ({
+        id: item.id,
+        trigger: item.slash!,
+        title: item.title,
+        description: item.description,
+        type: "builtin" as const,
+      })),
+  ])
+  const commands = createMemo<PromptInputV2Suggestion[]>(() =>
+    slashCommands().map((item) => ({
+      id: item.id,
+      kind: "command",
+      label: `/${item.trigger}`,
+      trigger: item.trigger,
+      title: item.title,
+      description: item.description,
+      keybind: command.keybindParts(item.id),
+    })),
+  )
+  const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
+  const controller = createPromptInputV2Controller({
+    store: () => prompt.capture().store,
+    state: interaction,
+    identity: () => prompt.capture(),
+    history: {
+      entries: (mode) =>
+        history.entries(mode).map((value) => {
+          const entry = normalizePromptHistoryEntry(value)
+          return { prompt: entry.prompt, metadata: entry.comments }
+        }),
+      add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
+      capture: historyComments,
+      restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
+    },
+    commands,
+    context,
+    searchContextFiles: async (query) =>
+      (await files.searchFilesAndDirectories(query)).map((path) => ({
+        id: `file:${path}`,
+        kind: "file",
+        label: path,
+        path,
+        mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
+      })),
+    onContextRemove(item) {
+      if (item?.commentID) comments.remove(item.path, item.commentID)
+    },
+    openAttachment: (attachment) =>
+      dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
+    openContext(key) {
+      const item = controller.contextItem(key)
+      if (item) openComment(item, props, sync, layout, files, comments)
+    },
+    onEditor(element) {
+      editor = element as HTMLDivElement
+      props.ref?.(editor)
+    },
+    onSuggestionSelect(item) {
+      if (item.kind !== "command") return
+      const selected = slashCommands().find((entry) => entry.id === item.id)
+      if (!selected || selected.type === "custom") return
+      return () => command.trigger(selected.id, "slash")
+    },
+    attachments: {
+      picker: platform.openAttachmentPickerDialog,
+      directory: () => sdk().directory,
+      isDialogActive: () => !!dialog.active,
+      warn: () =>
+        showToast({
+          title: language.t("prompt.toast.pasteUnsupported.title"),
+          description: language.t("prompt.toast.pasteUnsupported.description"),
+        }),
+      onError: (error) =>
+        showToast({
+          variant: "error",
+          title: language.t("common.requestFailed"),
+          description: error instanceof Error ? error.message : String(error),
+        }),
+      readClipboardImage: platform.readClipboardImage,
+      getPathForFile: platform.getPathForFile,
+    },
+    view: {
+      placeholder: designPlaceholder,
+      agent:
+        props.controls.agents.visible && props.controls.agents.options.length > 0
+          ? {
+              options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
+              current: () => props.controls.agents.current,
+              onSelect: props.controls.agents.select,
+              keybind: () => command.keybindParts("agent.cycle"),
+            }
+          : undefined,
+      variant: {
+        options: () => variants().map((value) => ({ id: value, label: value })),
+        current: () => props.controls.model.selection.variant.current() ?? "default",
+        onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
+        keybind: () => command.keybindParts("model.variant.cycle"),
+      },
+      submit: {
+        stopping,
+        working,
+        onSubmit: () => void submission.handleSubmit(new Event("submit")),
+        onStop: () => void submission.abort(),
+      },
+    },
+  })
+  Object.defineProperty(controller, "model", { get: () => props.controls.model })
+  return controller as PromptInputV2ComposerController
+}
+
+function PromptInputV2ModelControl(props: {
+  loading: boolean
+  paid: boolean
+  title: string
+  keybind: string[]
+  model: PromptInputV2ComposerController["model"]["selection"]
+  providerID?: string
+  modelName: string
+  onClose: () => void
+  onUnpaidClick: () => void
+}) {
+  const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
+  const content = () => (
+    <>
+      <Show when={props.providerID}>
+        {(providerID) => (
+          <ProviderIcon
+            id={providerID()}
+            class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
+            style={{ "will-change": "opacity", transform: "translateZ(0)" }}
+          />
+        )}
+      </Show>
+      <span class="truncate leading-4">{props.modelName}</span>
+      <span class="-ml-0.5 -mr-1 flex shrink-0">
+        <Icon name="chevron-down" />
+      </span>
+    </>
+  )
+  return (
+    <Show when={!props.loading}>
+      <TooltipV2
+        placement="top"
+        gutter={4}
+        value={
+          <>
+            {props.title}
+            <KeybindV2 keys={props.keybind} variant="neutral" />
+          </>
+        }
+      >
+        <Show
+          when={props.paid}
+          fallback={
+            <ButtonV2
+              data-action="prompt-model"
+              variant="ghost-muted"
+              size="normal"
+              class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
+              classList={{ "animate-in fade-in": shouldAnimate() }}
+              style={{ height: "28px" }}
+              onClick={props.onUnpaidClick}
+            >
+              {content()}
+            </ButtonV2>
+          }
+        >
+          <ModelSelectorPopoverV2
+            model={props.model}
+            triggerAs={ButtonV2}
+            triggerProps={{
+              variant: "ghost-muted",
+              size: "normal",
+              style: { height: "28px" },
+              class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
+              classList: { "animate-in fade-in": shouldAnimate() },
+              "data-action": "prompt-model",
+            }}
+            onClose={props.onClose}
+          >
+            {content()}
+          </ModelSelectorPopoverV2>
+        </Show>
+      </TooltipV2>
+    </Show>
+  )
+}
+
+function openComment(
+  item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
+  props: PromptInputV2ControllerProps,
+  sync: ReturnType<typeof useSync>,
+  layout: ReturnType<typeof useLayout>,
+  files: ReturnType<typeof useFile>,
+  comments: ReturnType<typeof useComments>,
+) {
+  if (!item.commentID) return
+  const focus = { file: item.path, id: item.commentID }
+  comments.setActive(focus)
+  const queueFocus = (attempts = 6) => {
+    requestAnimationFrame(() => {
+      comments.setFocus({ ...focus })
+      if (attempts <= 0) return
+      requestAnimationFrame(() => {
+        const current = comments.focus()
+        if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
+      })
+    })
+  }
+  const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
+  const review =
+    item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
+  if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
+  if (review) {
+    layout.fileTree.setTab("changes")
+    props.controls.session.tabs.setActive("review")
+    queueFocus()
+    return
+  }
+  layout.fileTree.setTab("all")
+  const tab = files.tab(item.path)
+  void props.controls.session.tabs.open(tab)
+  props.controls.session.tabs.setActive(tab)
+  void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
+}

+ 300 - 889
packages/app/src/components/prompt-input.tsx

@@ -13,8 +13,6 @@ import {
   Match,
   type JSX,
 } from "solid-js"
-import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
-import type { useLocal } from "@/context/local"
 import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
 import {
   ContentPart,
@@ -49,7 +47,6 @@ import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialo
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
 import { useCommand } from "@/context/command"
-import { Persist, persisted } from "@/utils/persist"
 import { usePermission } from "@/context/permission"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
@@ -60,121 +57,34 @@ import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
 import {
   canNavigateHistoryAtCursor,
   navigatePromptHistory,
-  prependHistoryEntry,
   type PromptHistoryComment,
   type PromptHistoryEntry,
-  type PromptHistoryStoredEntry,
   promptLength,
 } from "./prompt-input/history"
-import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
+import {
+  createPersistedPromptInputHistory,
+  createPromptInputHistory,
+  type PromptInputHistory,
+} from "./prompt-input/history-store"
+import {
+  type PromptInputControls,
+  type PromptInputProps,
+  type PromptInputState,
+  type PromptInputSubmission,
+} from "./prompt-input/contracts"
+import { createPromptSubmit } from "./prompt-input/submit"
 import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
 import { PromptContextItems } from "./prompt-input/context-items"
 import { PromptImageAttachments } from "./prompt-input/image-attachments"
 import { PromptDragOverlay } from "./prompt-input/drag-overlay"
-import { promptPlaceholder } from "./prompt-input/placeholder"
+import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder"
 import { createPromptInputTransientState } from "./prompt-input/transient-state"
 import { showToast } from "@/utils/toast"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
 
-export type PromptInputState = ReturnType<typeof usePrompt>
-
-export type PromptInputHistory = {
-  entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
-  add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
-}
-
-export type PromptInputSubmission = {
-  abort: () => Promise<void> | void
-  handleSubmit: (event: Event) => Promise<void> | void
-}
-
-export type PromptInputControls = {
-  agents: {
-    available: { name: string; hidden?: boolean; mode: string }[]
-    options: string[]
-    current: string
-    loading: boolean
-    visible: boolean
-    select: (name: string | undefined) => void
-  }
-  model: {
-    selection: ReturnType<typeof useLocal>["model"]
-    paid: boolean
-    loading: boolean
-  }
-  session: {
-    id?: string
-    tabs: {
-      active: () => string | undefined
-      all: () => string[]
-      open: (tab: string) => void | Promise<void>
-      setActive: (tab: string) => void
-    }
-    reviewPanel: {
-      opened: () => boolean
-      open: () => void
-    }
-  }
-  newLayoutDesigns: boolean
-}
-
-export function createPromptInputHistory(): PromptInputHistory {
-  const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
-  const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
-  return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
-}
-
-type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
-
-function createPromptInputHistoryStore(
-  normal: Store<PromptHistoryState>,
-  setNormal: SetStoreFunction<PromptHistoryState>,
-  shell: Store<PromptHistoryState>,
-  setShell: SetStoreFunction<PromptHistoryState>,
-): PromptInputHistory {
-  return {
-    entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
-    add(prompt, mode, comments) {
-      const current = mode === "shell" ? shell : normal
-      const setCurrent = mode === "shell" ? setShell : setNormal
-      const next = prependHistoryEntry(current.entries, prompt, comments)
-      if (next === current.entries) return
-      setCurrent("entries", next)
-    },
-  }
-}
-
-function createPersistedPromptInputHistory() {
-  const [normal, setNormal] = persisted(
-    Persist.global("prompt-history", ["prompt-history.v1"]),
-    createStore<PromptHistoryState>({ entries: [] }),
-  )
-  const [shell, setShell] = persisted(
-    Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
-    createStore<PromptHistoryState>({ entries: [] }),
-  )
-  return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
-}
-
-export interface PromptInputProps {
-  class?: string
-  variant?: "dock" | "new-session"
-  state?: PromptInputState
-  history?: PromptInputHistory
-  submission?: PromptInputSubmission
-  controls: PromptInputControls
-  ref?: (el: HTMLDivElement) => void
-  newSessionWorktree?: string
-  onNewSessionWorktreeReset?: () => void
-  edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
-  onEditLoaded?: () => void
-  shouldQueue?: () => boolean
-  onQueue?: (draft: FollowupDraft) => void
-  onAbort?: () => void
-  onSubmit?: () => void
-  toolbar?: JSX.Element
-}
+export { createPromptInputHistory }
+export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
 
 const EXAMPLES = [
   "prompt.example.1",
@@ -1191,28 +1101,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     return true
   }
 
-  const openCommands = () => {
-    const populated = prompt.dirty() || commentCount() > 0
-    requestAnimationFrame(() => {
-      if (!populated) {
-        if (!addPart({ type: "text", content: "/", start: 0, end: 0 })) return
-        slashOnInput("")
-        setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
-        return
-      }
-      slashOnInput("")
-      setStore({ popover: "slash", slashMenu: true, slashMenuQuery: "" })
-    })
-  }
-
-  const openContext = () => {
-    requestAnimationFrame(() => {
-      if (!addPart({ type: "text", content: "@", start: 0, end: 0 })) return
-      atOnInput("")
-      setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
-    })
-  }
-
   const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
     history.add(prompt, mode, mode === "shell" ? [] : historyComments())
   }
@@ -1539,50 +1427,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     (p) => p,
   )
 
-  const designPlaceholder = () => {
-    if (store.mode === "shell") return placeholder()
-    return "Ask anything, / for commands, @ for context..."
-  }
-
-  const modelControlState = createMemo<ComposerModelControlState>(() => ({
-    loading: providersLoading(),
-    shouldAnimate: providersShouldFadeIn(),
-    paid: props.controls.model.paid,
-    title: language.t("command.model.choose"),
-    keybind: command.keybindParts("model.choose"),
-    model: props.controls.model.selection,
-    providerID: props.controls.model.selection.current()?.provider?.id,
-    modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
-    newLayoutDesigns: props.controls.newLayoutDesigns,
-    style: control(),
-    onClose: restoreFocus,
-    onUnpaidClick: () => {
-      if (props.controls.newLayoutDesigns) {
-        dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
-        return
-      }
-      dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
-    },
-  }))
-
-  const newSession = () => props.variant === "new-session"
   const bindEditorRef = (el: HTMLDivElement) => {
     editorRef = el
     restoreEndOnFocus = true
     props.ref?.(el)
   }
-  const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
-  const agentControlState = createMemo<ComposerAgentControlState>(() => ({
-    title: language.t("command.agent.cycle"),
-    keybind: command.keybindParts("agent.cycle"),
-    options: props.controls.agents.options,
-    current: props.controls.agents.current,
-    style: control(),
-    onSelect: (value) => {
-      props.controls.agents.select(value)
-      restoreFocus()
-    },
-  }))
   return (
     <div class="relative size-full flex flex-col gap-0">
       {(promptReady(), null)}
@@ -1607,773 +1456,335 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         onSlashMenuKeyDown={handleSlashMenuKeyDown}
         commandKeybind={command.keybind}
         commandKeybindParts={command.keybindParts}
-        newLayoutDesigns={props.controls.newLayoutDesigns}
+        newLayoutDesigns={false}
         t={(key) => language.t(key as Parameters<typeof language.t>[0])}
       />
-      <Switch>
-        <Match when={props.controls.newLayoutDesigns}>
-          <div class="flex flex-col gap-3">
-            <DockShellForm
-              data-component={newSession() ? "session-new-composer" : "session-composer"}
-              onSubmit={handleSubmit}
+      <DockShellForm
+        data-dock-border-underlay="legacy"
+        onSubmit={handleSubmit}
+        classList={{
+          "group/prompt-input": true,
+          "border-icon-info-active border-dashed": store.draggingType !== null,
+          [props.class ?? ""]: !!props.class,
+        }}
+      >
+        <PromptDragOverlay
+          type={store.draggingType}
+          label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
+        />
+        <PromptContextItems
+          items={contextItems()}
+          active={(item) => {
+            const active = comments.active()
+            return !!item.commentID && item.commentID === active?.id && item.path === active?.file
+          }}
+          openComment={openComment}
+          remove={(item) => {
+            if (item.commentID) comments.remove(item.path, item.commentID)
+            prompt.context.remove(item.key)
+          }}
+          newLayoutDesigns={false}
+          t={(key) => language.t(key as Parameters<typeof language.t>[0])}
+        />
+        <PromptImageAttachments
+          attachments={imageAttachments()}
+          onOpen={(attachment) =>
+            dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
+          }
+          onRemove={removeAttachment}
+          removeLabel={language.t("prompt.attachment.remove")}
+          newLayoutDesigns={false}
+        />
+        <div
+          class="relative"
+          onMouseDown={(e) => {
+            const target = e.target
+            if (!(target instanceof HTMLElement)) return
+            if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
+              return
+            }
+            editorRef?.focus()
+          }}
+        >
+          <div
+            class="relative max-h-[240px] overflow-y-auto no-scrollbar"
+            ref={(el) => (scrollRef = el)}
+            style={{ "scroll-padding-bottom": space }}
+          >
+            <div
+              data-component="prompt-input"
+              ref={bindEditorRef}
+              role="textbox"
+              aria-multiline="true"
+              aria-label={placeholder()}
+              contenteditable="true"
+              autocapitalize={store.mode === "normal" ? "sentences" : "off"}
+              autocorrect={store.mode === "normal" ? "on" : "off"}
+              spellcheck={store.mode === "normal"}
+              inputMode="text"
+              // @ts-expect-error
+              autocomplete="off"
+              onInput={handleInput}
+              onPaste={handlePaste}
+              onCompositionStart={handleCompositionStart}
+              onCompositionEnd={handleCompositionEnd}
+              onFocus={handleFocus}
+              onBlur={handleBlur}
+              onKeyDown={handleKeyDown}
               classList={{
-                "group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
-                "border-icon-info-active border-dashed": store.draggingType !== null,
-                [props.class ?? ""]: !!props.class,
+                "select-text": true,
+                "w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
+                "[&_[data-type=file]]:text-syntax-property": true,
+                "[&_[data-type=agent]]:text-syntax-type": true,
+                "font-mono!": store.mode === "shell",
               }}
+              style={{ "padding-bottom": space }}
+            />
+            <div
+              class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
+              classList={{ "font-mono!": store.mode === "shell" }}
+              style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
             >
-              <PromptDragOverlay
-                type={store.draggingType}
-                label={language.t(
-                  store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
-                )}
-              />
-              <PromptContextItems
-                items={contextItems().filter((item) => !isCommentItem(item))}
-                active={(item) => {
-                  const active = comments.active()
-                  return !!item.commentID && item.commentID === active?.id && item.path === active?.file
-                }}
-                openComment={openComment}
-                remove={(item) => {
-                  if (item.commentID) comments.remove(item.path, item.commentID)
-                  prompt.context.remove(item.key)
-                }}
-                newLayoutDesigns={props.controls.newLayoutDesigns}
-                t={(key) => language.t(key as Parameters<typeof language.t>[0])}
-              />
-              <PromptImageAttachments
-                attachments={imageAttachments()}
-                onOpen={(attachment) =>
-                  dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
-                }
-                onRemove={removeAttachment}
-                removeLabel={language.t("prompt.attachment.remove")}
-                newLayoutDesigns={props.controls.newLayoutDesigns}
-                comments={contextItems().filter(isCommentItem)}
-                commentActive={(item) => {
-                  const active = comments.active()
-                  return !!item.commentID && item.commentID === active?.id && item.path === active?.file
-                }}
-                onOpenComment={openComment}
-                onRemoveComment={(item) => {
-                  if (item.commentID) comments.remove(item.path, item.commentID)
-                  prompt.context.remove(item.key)
-                }}
-              />
-              <div
-                class="relative min-h-[52px]"
-                onMouseDown={(e) => {
-                  const target = e.target
-                  if (!(target instanceof HTMLElement)) return
-                  if (target.closest('[data-action^="prompt-"]')) return
-                  editorRef?.focus()
-                }}
-              >
-                <div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
-                  <div
-                    data-component="prompt-input"
-                    ref={bindEditorRef}
-                    role="textbox"
-                    aria-multiline="true"
-                    aria-label={designPlaceholder()}
-                    contenteditable="true"
-                    autocapitalize={store.mode === "normal" ? "sentences" : "off"}
-                    autocorrect={store.mode === "normal" ? "on" : "off"}
-                    spellcheck={store.mode === "normal"}
-                    inputMode="text"
-                    // @ts-expect-error
-                    autocomplete="off"
-                    onInput={handleInput}
-                    onPaste={handlePaste}
-                    onCompositionStart={handleCompositionStart}
-                    onCompositionEnd={handleCompositionEnd}
-                    onFocus={handleFocus}
-                    onBlur={handleBlur}
-                    onKeyDown={handleKeyDown}
-                    classList={{
-                      "select-text": true,
-                      "min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
-                      "[&_[data-type=file]]:text-syntax-property": true,
-                      "[&_[data-type=agent]]:text-syntax-type": true,
-                      "font-mono!": store.mode === "shell",
-                    }}
-                  />
-                  <div
-                    data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
-                    class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
-                    classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
-                  >
-                    {designPlaceholder()}
-                  </div>
-                </div>
-              </div>
-              <div class="flex h-11 items-center px-2">
-                <div class="flex min-w-0 flex-1 items-center gap-1">
-                  {fileAttachmentInput()}
-                  <TooltipV2
-                    placement="top"
-                    value={
-                      <>
-                        {language.t("prompt.menu.addImagesAndFiles")}
-                        <KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
-                      </>
-                    }
-                  >
-                    <MenuV2 gutter={6} modal={false} placement="top-start">
-                      <MenuV2.Trigger
-                        as={IconButtonV2}
-                        data-action="prompt-attach"
-                        type="button"
-                        icon={<IconV2 name="plus" />}
-                        variant="ghost-muted"
-                        size="large"
-                        style={buttons()}
-                        disabled={store.mode !== "normal"}
-                        tabIndex={store.mode === "normal" ? undefined : -1}
-                        aria-label={language.t("prompt.menu.addImagesAndFiles")}
-                      />
-                      <MenuV2.Portal>
-                        <MenuV2.Content style={{ "min-width": "180px" }}>
-                          <MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
-                            {language.t("prompt.menu.imagesAndFiles")}
-                          </MenuV2.Item>
-                          <MenuV2.Separator />
-                          <MenuV2.Item onSelect={openCommands} shortcut="/">
-                            {language.t("prompt.menu.commands")}
-                          </MenuV2.Item>
-                          <MenuV2.Item onSelect={openContext} shortcut="@">
-                            {language.t("prompt.menu.context")}
-                          </MenuV2.Item>
-                          <MenuV2.Item onSelect={() => setMode("shell")} shortcut="!">
-                            {language.t("prompt.menu.shellCommand")}
-                          </MenuV2.Item>
-                        </MenuV2.Content>
-                      </MenuV2.Portal>
-                    </MenuV2>
-                  </TooltipV2>
-                  <Show when={showAgentControl()}>
-                    <ComposerAgentControl state={agentControlState()} />
-                  </Show>
-                  {props.toolbar}
-                  <ComposerModelControl state={modelControlState()} />
-                  <Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
-                    <div
-                      data-component="prompt-variant-control"
-                      class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
-                      classList={{
-                        "animate-in fade-in": providersShouldFadeIn(),
-                        "hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
-                          !props.controls.model.selection.variant.current() && !store.variantOpen,
-                      }}
-                    >
-                      <TooltipV2
-                        placement="top"
-                        gutter={4}
-                        value={
-                          <>
-                            {language.t("command.model.variant.cycle")}
-                            <KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
-                          </>
-                        }
-                      >
-                        <MenuV2
-                          gutter={6}
-                          modal={false}
-                          placement="top-start"
-                          onOpenChange={(open) => setStore("variantOpen", open)}
-                        >
-                          <MenuV2.Trigger
-                            as={ButtonV2}
-                            data-action="prompt-model-variant"
-                            variant="ghost-muted"
-                            size="normal"
-                            class="max-w-[160px] justify-start capitalize"
-                            style={control()}
-                          >
-                            <span class="truncate leading-5">
-                              {props.controls.model.selection.variant.current() ?? language.t("common.default")}
-                            </span>
-                            <span class="-ml-0.5 -mr-1 flex shrink-0">
-                              <Icon name="chevron-down" size="small" />
-                            </span>
-                          </MenuV2.Trigger>
-                          <MenuV2.Portal>
-                            <MenuV2.Content>
-                              <MenuV2.RadioGroup
-                                value={props.controls.model.selection.variant.current() ?? "default"}
-                                onChange={(value) => {
-                                  props.controls.model.selection.variant.set(value === "default" ? undefined : value)
-                                  restoreFocus()
-                                }}
-                              >
-                                {variants().map((value) => (
-                                  <MenuV2.RadioItem value={value} class="capitalize">
-                                    {value === "default" ? language.t("common.default") : value}
-                                  </MenuV2.RadioItem>
-                                ))}
-                              </MenuV2.RadioGroup>
-                            </MenuV2.Content>
-                          </MenuV2.Portal>
-                        </MenuV2>
-                      </TooltipV2>
-                    </div>
-                  </Show>
-                </div>
-                <TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
-                  <IconButton
-                    data-action="prompt-submit"
-                    type="submit"
-                    disabled={!working() && blank()}
-                    tabIndex={store.mode === "normal" ? undefined : -1}
-                    icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
-                    variant="primary"
-                    class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
-                    style={{
-                      "background-image":
-                        "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
-                    }}
-                    aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
-                  />
-                </TooltipV2>
-              </div>
-            </DockShellForm>
+              {placeholder()}
+            </div>
           </div>
-        </Match>
-        <Match when>
-          <DockShellForm
-            onSubmit={handleSubmit}
-            classList={{
-              "group/prompt-input": true,
-              "focus-within:shadow-xs-border": true,
-              "border-icon-info-active border-dashed": store.draggingType !== null,
-              [props.class ?? ""]: !!props.class,
+
+          <div
+            aria-hidden="true"
+            class="pointer-events-none absolute inset-x-0 bottom-0"
+            style={{
+              height: space,
+              background:
+                "linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
             }}
-          >
-            <PromptDragOverlay
-              type={store.draggingType}
-              label={language.t(
-                store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
-              )}
-            />
-            <PromptContextItems
-              items={contextItems()}
-              active={(item) => {
-                const active = comments.active()
-                return !!item.commentID && item.commentID === active?.id && item.path === active?.file
-              }}
-              openComment={openComment}
-              remove={(item) => {
-                if (item.commentID) comments.remove(item.path, item.commentID)
-                prompt.context.remove(item.key)
+          />
+
+          <div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
+            <input
+              ref={fileInputRef}
+              type="file"
+              multiple
+              accept={ACCEPTED_FILE_TYPES.join(",")}
+              class="hidden"
+              onChange={(e) => {
+                const list = e.currentTarget.files
+                if (list) void addAttachments(Array.from(list))
+                e.currentTarget.value = ""
               }}
-              newLayoutDesigns={props.controls.newLayoutDesigns}
-              t={(key) => language.t(key as Parameters<typeof language.t>[0])}
-            />
-            <PromptImageAttachments
-              attachments={imageAttachments()}
-              onOpen={(attachment) =>
-                dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
-              }
-              onRemove={removeAttachment}
-              removeLabel={language.t("prompt.attachment.remove")}
-              newLayoutDesigns={props.controls.newLayoutDesigns}
             />
+
+            <div class="flex items-center gap-1 pointer-events-auto">
+              <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
+                <IconButton
+                  data-action="prompt-submit"
+                  type="submit"
+                  disabled={!working() && blank()}
+                  tabIndex={store.mode === "normal" ? undefined : -1}
+                  icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
+                  variant="primary"
+                  class="size-8"
+                  aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
+                />
+              </Tooltip>
+            </div>
+          </div>
+
+          <div class="pointer-events-none absolute bottom-2 left-2">
             <div
-              class="relative"
-              onMouseDown={(e) => {
-                const target = e.target
-                if (!(target instanceof HTMLElement)) return
-                if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
-                  return
-                }
-                editorRef?.focus()
+              aria-hidden={store.mode !== "normal"}
+              class="pointer-events-auto"
+              style={{
+                "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
               }}
             >
-              <div
-                class="relative max-h-[240px] overflow-y-auto no-scrollbar"
-                ref={(el) => (scrollRef = el)}
-                style={{ "scroll-padding-bottom": space }}
+              <TooltipKeybind
+                placement="top"
+                title={language.t("prompt.action.attachFile")}
+                keybind={command.keybind("file.attach")}
               >
-                <div
-                  data-component="prompt-input"
-                  ref={bindEditorRef}
-                  role="textbox"
-                  aria-multiline="true"
-                  aria-label={placeholder()}
-                  contenteditable="true"
-                  autocapitalize={store.mode === "normal" ? "sentences" : "off"}
-                  autocorrect={store.mode === "normal" ? "on" : "off"}
-                  spellcheck={store.mode === "normal"}
-                  inputMode="text"
-                  // @ts-expect-error
-                  autocomplete="off"
-                  onInput={handleInput}
-                  onPaste={handlePaste}
-                  onCompositionStart={handleCompositionStart}
-                  onCompositionEnd={handleCompositionEnd}
-                  onFocus={handleFocus}
-                  onBlur={handleBlur}
-                  onKeyDown={handleKeyDown}
-                  classList={{
-                    "select-text": true,
-                    "w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
-                    "[&_[data-type=file]]:text-syntax-property": true,
-                    "[&_[data-type=agent]]:text-syntax-type": true,
-                    "font-mono!": store.mode === "shell",
-                  }}
-                  style={{ "padding-bottom": space }}
-                />
-                <div
-                  class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
-                  classList={{ "font-mono!": store.mode === "shell" }}
-                  style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
+                <Button
+                  data-action="prompt-attach"
+                  type="button"
+                  variant="ghost"
+                  class="size-8 p-0"
+                  style={buttons()}
+                  onClick={pick}
+                  disabled={store.mode !== "normal"}
+                  tabIndex={store.mode === "normal" ? undefined : -1}
+                  aria-label={language.t("prompt.action.attachFile")}
                 >
-                  {placeholder()}
-                </div>
-              </div>
-
+                  <Icon name="plus" class="size-4.5" />
+                </Button>
+              </TooltipKeybind>
+            </div>
+          </div>
+        </div>
+      </DockShellForm>
+      <Show when={store.mode === "normal" || store.mode === "shell"}>
+        <DockTray attach="top">
+          <div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
+            <div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
               <div
-                aria-hidden="true"
-                class="pointer-events-none absolute inset-x-0 bottom-0"
+                class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
                 style={{
-                  height: space,
-                  background:
-                    "linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
+                  padding: "0 0px 0 8px",
+                  ...shell(),
                 }}
-              />
-
-              <div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
-                <input
-                  ref={fileInputRef}
-                  type="file"
-                  multiple
-                  accept={ACCEPTED_FILE_TYPES.join(",")}
-                  class="hidden"
-                  onChange={(e) => {
-                    const list = e.currentTarget.files
-                    if (list) void addAttachments(Array.from(list))
-                    e.currentTarget.value = ""
-                  }}
-                />
-
-                <div class="flex items-center gap-1 pointer-events-auto">
-                  <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
-                    <IconButton
-                      data-action="prompt-submit"
-                      type="submit"
-                      disabled={!working() && blank()}
-                      tabIndex={store.mode === "normal" ? undefined : -1}
-                      icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
-                      variant="primary"
-                      class="size-8"
-                      aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
-                    />
-                  </Tooltip>
-                </div>
-              </div>
-
-              <div class="pointer-events-none absolute bottom-2 left-2">
-                <div
-                  aria-hidden={store.mode !== "normal"}
-                  class="pointer-events-auto"
-                  style={{
-                    "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
+              >
+                <Icon name="console" />
+                <span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
+                <div class="flex-1" />
+                <Button
+                  variant="ghost"
+                  class="text-text-base"
+                  onClick={() => {
+                    setStore("mode", "normal")
                   }}
                 >
-                  <TooltipKeybind
-                    placement="top"
-                    title={language.t("prompt.action.attachFile")}
-                    keybind={command.keybind("file.attach")}
-                  >
-                    <Button
-                      data-action="prompt-attach"
-                      type="button"
-                      variant="ghost"
-                      class="size-8 p-0"
-                      style={buttons()}
-                      onClick={pick}
-                      disabled={store.mode !== "normal"}
-                      tabIndex={store.mode === "normal" ? undefined : -1}
-                      aria-label={language.t("prompt.action.attachFile")}
-                    >
-                      <Icon name="plus" class="size-4.5" />
-                    </Button>
-                  </TooltipKeybind>
-                </div>
+                  {language.t("common.cancel")}
+                </Button>
               </div>
-            </div>
-          </DockShellForm>
-          <Show when={store.mode === "normal" || store.mode === "shell"}>
-            <DockTray attach="top">
-              <div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
-                <div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
+              <div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
+                <Show when={!agentsLoading()}>
                   <div
-                    class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
-                    style={{
-                      padding: "0 0px 0 8px",
-                      ...shell(),
-                    }}
+                    data-component="prompt-agent-control"
+                    classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
                   >
-                    <Icon name="console" />
-                    <span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
-                    <div class="flex-1" />
-                    <Button
-                      variant="ghost"
-                      class="text-text-base"
-                      onClick={() => {
-                        setStore("mode", "normal")
-                      }}
+                    <TooltipKeybind
+                      placement="top"
+                      gutter={4}
+                      title={language.t("command.agent.cycle")}
+                      keybind={command.keybind("agent.cycle")}
                     >
-                      {language.t("common.cancel")}
-                    </Button>
+                      <Select
+                        size="normal"
+                        options={props.controls.agents.options}
+                        current={props.controls.agents.current}
+                        onSelect={(value) => {
+                          props.controls.agents.select(value)
+                          restoreFocus()
+                        }}
+                        class="capitalize max-w-[160px] text-text-base"
+                        valueClass="truncate text-13-regular text-text-base"
+                        triggerStyle={control()}
+                        triggerProps={{ "data-action": "prompt-agent" }}
+                        variant="ghost"
+                      />
+                    </TooltipKeybind>
                   </div>
-                  <div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
-                    <Show when={!agentsLoading()}>
+                </Show>
+                <Show when={!providersLoading()}>
+                  <Show when={store.mode !== "shell"}>
+                    <div
+                      data-component="prompt-model-control"
+                      classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
+                    >
+                      <Show
+                        when={props.controls.model.paid}
+                        fallback={
+                          <TooltipKeybind
+                            placement="top"
+                            gutter={4}
+                            title={language.t("command.model.choose")}
+                            keybind={command.keybind("model.choose")}
+                          >
+                            <Button
+                              data-action="prompt-model"
+                              as="div"
+                              variant="ghost"
+                              size="normal"
+                              class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
+                              style={control()}
+                              onClick={() => {
+                                dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
+                              }}
+                            >
+                              <Show when={props.controls.model.selection.current()?.provider?.id}>
+                                <ProviderIcon
+                                  id={props.controls.model.selection.current()?.provider?.id ?? ""}
+                                  class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
+                                  style={{ "will-change": "opacity", transform: "translateZ(0)" }}
+                                />
+                              </Show>
+                              <span class="truncate">
+                                {props.controls.model.selection.current()?.name ??
+                                  language.t("dialog.model.select.title")}
+                              </span>
+                              <Icon name="chevron-down" size="small" class="shrink-0" />
+                            </Button>
+                          </TooltipKeybind>
+                        }
+                      >
+                        <TooltipKeybind
+                          placement="top"
+                          gutter={4}
+                          title={language.t("command.model.choose")}
+                          keybind={command.keybind("model.choose")}
+                        >
+                          <ModelSelectorPopover
+                            model={props.controls.model.selection}
+                            triggerAs={Button}
+                            triggerProps={{
+                              variant: "ghost",
+                              size: "normal",
+                              style: control(),
+                              class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
+                              "data-action": "prompt-model",
+                            }}
+                            onClose={restoreFocus}
+                          >
+                            <Show when={props.controls.model.selection.current()?.provider?.id}>
+                              <ProviderIcon
+                                id={props.controls.model.selection.current()?.provider?.id ?? ""}
+                                class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
+                                style={{ "will-change": "opacity", transform: "translateZ(0)" }}
+                              />
+                            </Show>
+                            <span class="truncate">
+                              {props.controls.model.selection.current()?.name ??
+                                language.t("dialog.model.select.title")}
+                            </span>
+                            <Icon name="chevron-down" size="small" class="shrink-0" />
+                          </ModelSelectorPopover>
+                        </TooltipKeybind>
+                      </Show>
+                    </div>
+                    <Show when={showVariantControl()}>
                       <div
-                        data-component="prompt-agent-control"
-                        classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
+                        data-component="prompt-variant-control"
+                        classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
                       >
                         <TooltipKeybind
                           placement="top"
                           gutter={4}
-                          title={language.t("command.agent.cycle")}
-                          keybind={command.keybind("agent.cycle")}
+                          title={language.t("command.model.variant.cycle")}
+                          keybind={command.keybind("model.variant.cycle")}
                         >
                           <Select
                             size="normal"
-                            options={props.controls.agents.options}
-                            current={props.controls.agents.current}
+                            options={variants()}
+                            current={props.controls.model.selection.variant.current() ?? "default"}
+                            label={(x) => (x === "default" ? language.t("common.default") : x)}
                             onSelect={(value) => {
-                              props.controls.agents.select(value)
+                              props.controls.model.selection.variant.set(value === "default" ? undefined : value)
                               restoreFocus()
                             }}
                             class="capitalize max-w-[160px] text-text-base"
                             valueClass="truncate text-13-regular text-text-base"
                             triggerStyle={control()}
-                            triggerProps={{ "data-action": "prompt-agent" }}
+                            triggerProps={{ "data-action": "prompt-model-variant" }}
                             variant="ghost"
                           />
                         </TooltipKeybind>
                       </div>
                     </Show>
-                    <Show when={!providersLoading()}>
-                      <Show when={store.mode !== "shell"}>
-                        <div
-                          data-component="prompt-model-control"
-                          classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
-                        >
-                          <Show
-                            when={props.controls.model.paid}
-                            fallback={
-                              <TooltipKeybind
-                                placement="top"
-                                gutter={4}
-                                title={language.t("command.model.choose")}
-                                keybind={command.keybind("model.choose")}
-                              >
-                                <Button
-                                  data-action="prompt-model"
-                                  as="div"
-                                  variant="ghost"
-                                  size="normal"
-                                  class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
-                                  style={control()}
-                                  onClick={() => {
-                                    dialog.show(() => (
-                                      <DialogSelectModelUnpaid model={props.controls.model.selection} />
-                                    ))
-                                  }}
-                                >
-                                  <Show when={props.controls.model.selection.current()?.provider?.id}>
-                                    <ProviderIcon
-                                      id={props.controls.model.selection.current()?.provider?.id ?? ""}
-                                      class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
-                                      style={{ "will-change": "opacity", transform: "translateZ(0)" }}
-                                    />
-                                  </Show>
-                                  <span class="truncate">
-                                    {props.controls.model.selection.current()?.name ??
-                                      language.t("dialog.model.select.title")}
-                                  </span>
-                                  <Icon name="chevron-down" size="small" class="shrink-0" />
-                                </Button>
-                              </TooltipKeybind>
-                            }
-                          >
-                            <TooltipKeybind
-                              placement="top"
-                              gutter={4}
-                              title={language.t("command.model.choose")}
-                              keybind={command.keybind("model.choose")}
-                            >
-                              <ModelSelectorPopover
-                                model={props.controls.model.selection}
-                                triggerAs={Button}
-                                triggerProps={{
-                                  variant: "ghost",
-                                  size: "normal",
-                                  style: control(),
-                                  class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
-                                  "data-action": "prompt-model",
-                                }}
-                                onClose={restoreFocus}
-                              >
-                                <Show when={props.controls.model.selection.current()?.provider?.id}>
-                                  <ProviderIcon
-                                    id={props.controls.model.selection.current()?.provider?.id ?? ""}
-                                    class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
-                                    style={{ "will-change": "opacity", transform: "translateZ(0)" }}
-                                  />
-                                </Show>
-                                <span class="truncate">
-                                  {props.controls.model.selection.current()?.name ??
-                                    language.t("dialog.model.select.title")}
-                                </span>
-                                <Icon name="chevron-down" size="small" class="shrink-0" />
-                              </ModelSelectorPopover>
-                            </TooltipKeybind>
-                          </Show>
-                        </div>
-                        <Show when={showVariantControl()}>
-                          <div
-                            data-component="prompt-variant-control"
-                            classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
-                          >
-                            <TooltipKeybind
-                              placement="top"
-                              gutter={4}
-                              title={language.t("command.model.variant.cycle")}
-                              keybind={command.keybind("model.variant.cycle")}
-                            >
-                              <Select
-                                size="normal"
-                                options={variants()}
-                                current={props.controls.model.selection.variant.current() ?? "default"}
-                                label={(x) => (x === "default" ? language.t("common.default") : x)}
-                                onSelect={(value) => {
-                                  props.controls.model.selection.variant.set(value === "default" ? undefined : value)
-                                  restoreFocus()
-                                }}
-                                class="capitalize max-w-[160px] text-text-base"
-                                valueClass="truncate text-13-regular text-text-base"
-                                triggerStyle={control()}
-                                triggerProps={{ "data-action": "prompt-model-variant" }}
-                                variant="ghost"
-                              />
-                            </TooltipKeybind>
-                          </div>
-                        </Show>
-                      </Show>
-                    </Show>
-                  </div>
-                </div>
-              </div>
-            </DockTray>
-          </Show>
-        </Match>
-      </Switch>
-    </div>
-  )
-}
-
-type ComposerAgentControlState = {
-  title: string
-  keybind: string[]
-  options: string[]
-  current: string
-  style: JSX.CSSProperties | undefined
-  onSelect: (value: string | undefined) => void
-}
-
-type ComposerModelControlState = {
-  loading: boolean
-  shouldAnimate: boolean
-  paid: boolean
-  title: string
-  keybind: string[]
-  model: ReturnType<typeof useLocal>["model"]
-  providerID?: string
-  modelName: string
-  newLayoutDesigns: boolean
-  style: JSX.CSSProperties | undefined
-  onClose: () => void
-  onUnpaidClick: () => void
-}
-
-function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
-  return (
-    <div>
-      <TooltipV2
-        placement="top"
-        gutter={4}
-        value={
-          <>
-            {props.state.title}
-            <KeybindV2 keys={props.state.keybind} variant="neutral" />
-          </>
-        }
-      >
-        <MenuV2 gutter={6} modal={false} placement="top-start">
-          <MenuV2.Trigger
-            as={ButtonV2}
-            data-action="prompt-agent"
-            variant="ghost-muted"
-            size="normal"
-            class="max-w-[175px] justify-start ![font-weight:440]"
-            style={props.state.style}
-          >
-            <span class="truncate capitalize leading-5">{props.state.current}</span>
-            <span class="-ml-0.5 -mr-1 flex shrink-0">
-              <Icon name="chevron-down" size="small" />
-            </span>
-          </MenuV2.Trigger>
-          <MenuV2.Portal>
-            <MenuV2.Content>
-              <MenuV2.RadioGroup value={props.state.current} onChange={props.state.onSelect}>
-                {props.state.options.map((value) => (
-                  <MenuV2.RadioItem value={value} class="capitalize">
-                    {value}
-                  </MenuV2.RadioItem>
-                ))}
-              </MenuV2.RadioGroup>
-            </MenuV2.Content>
-          </MenuV2.Portal>
-        </MenuV2>
-      </TooltipV2>
-    </div>
-  )
-}
-
-function ComposerModelControl(props: { state: ComposerModelControlState }) {
-  return (
-    <Show when={!props.state.loading}>
-      <Show
-        when={props.state.paid}
-        fallback={
-          <TooltipV2
-            placement="top"
-            gutter={4}
-            value={
-              <>
-                {props.state.title}
-                <KeybindV2 keys={props.state.keybind} variant="neutral" />
-              </>
-            }
-          >
-            <Show
-              when={props.state.newLayoutDesigns}
-              fallback={
-                <Button
-                  data-action="prompt-model"
-                  as="div"
-                  variant="ghost"
-                  size="normal"
-                  class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
-                  classList={{ "animate-in fade-in": props.state.shouldAnimate }}
-                  style={props.state.style}
-                  onClick={props.state.onUnpaidClick}
-                >
-                  <Show when={props.state.providerID}>
-                    {(providerID) => (
-                      <ProviderIcon
-                        id={providerID()}
-                        class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
-                        style={{ "will-change": "opacity", transform: "translateZ(0)" }}
-                      />
-                    )}
                   </Show>
-                  <span class="truncate">{props.state.modelName}</span>
-                  <span class="-ml-1 shrink-0 flex size-fit">
-                    <Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
-                  </span>
-                </Button>
-              }
-            >
-              <ButtonV2
-                data-action="prompt-model"
-                variant="ghost-muted"
-                size="normal"
-                class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
-                classList={{ "animate-in fade-in": props.state.shouldAnimate }}
-                style={props.state.style}
-                onClick={props.state.onUnpaidClick}
-              >
-                <ModelControlContent state={props.state} v2 />
-              </ButtonV2>
-            </Show>
-          </TooltipV2>
-        }
-      >
-        <TooltipV2
-          placement="top"
-          gutter={4}
-          value={
-            <>
-              {props.state.title}
-              <KeybindV2 keys={props.state.keybind} variant="neutral" />
-            </>
-          }
-        >
-          <Show
-            when={props.state.newLayoutDesigns}
-            fallback={
-              <ModelSelectorPopover
-                model={props.state.model}
-                triggerAs={Button}
-                triggerProps={{
-                  variant: "ghost",
-                  size: "normal",
-                  style: props.state.style,
-                  class:
-                    "min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
-                  classList: { "animate-in fade-in": props.state.shouldAnimate },
-                  "data-action": "prompt-model",
-                }}
-                onClose={props.state.onClose}
-              >
-                <ModelControlContent state={props.state} />
-              </ModelSelectorPopover>
-            }
-          >
-            <ModelSelectorPopoverV2
-              model={props.state.model}
-              triggerAs={ButtonV2}
-              triggerProps={{
-                variant: "ghost-muted",
-                size: "normal",
-                style: props.state.style,
-                class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
-                classList: { "animate-in fade-in": props.state.shouldAnimate },
-                "data-action": "prompt-model",
-              }}
-              onClose={props.state.onClose}
-            >
-              <ModelControlContent state={props.state} v2 />
-            </ModelSelectorPopoverV2>
-          </Show>
-        </TooltipV2>
-      </Show>
-    </Show>
-  )
-}
-
-function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
-  return (
-    <>
-      <Show when={props.state.providerID}>
-        {(providerID) => (
-          <ProviderIcon
-            id={providerID()}
-            class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
-            style={{ "will-change": "opacity", transform: "translateZ(0)" }}
-          />
-        )}
+                </Show>
+              </div>
+            </div>
+          </div>
+        </DockTray>
       </Show>
-      <span class="truncate leading-4">{props.state.modelName}</span>
-      <span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
-        <Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
-      </span>
-    </>
+    </div>
   )
 }

+ 1 - 1
packages/app/src/components/prompt-input/attachments.ts

@@ -38,7 +38,7 @@ type PromptAttachmentsCoreInput = {
   getPathForFile?: (file: File) => string
 }
 
-type PromptAttachmentsInput = {
+export type PromptAttachmentsInput = {
   prompt: ReturnType<typeof usePrompt>
   editor: () => HTMLDivElement | undefined
   isDialogActive: () => boolean

+ 9 - 7
packages/app/src/components/prompt-input/build-request-parts.ts

@@ -89,13 +89,15 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
 }
 
 export function buildRequestParts(input: BuildRequestPartsInput) {
-  const requestParts: PromptRequestPart[] = [
-    {
-      id: Identifier.ascending("part"),
-      type: "text",
-      text: input.text,
-    },
-  ]
+  const requestParts: PromptRequestPart[] = input.text.trim()
+    ? [
+        {
+          id: Identifier.ascending("part"),
+          type: "text",
+          text: input.text,
+        },
+      ]
+    : []
 
   const files = input.prompt.filter(isFileAttachment).map((attachment) => {
     const path = absolute(input.sessionDirectory, attachment.path)

+ 57 - 0
packages/app/src/components/prompt-input/contracts.ts

@@ -0,0 +1,57 @@
+import type { useLocal } from "@/context/local"
+import type { Prompt, usePrompt } from "@/context/prompt"
+import type { PromptInputHistory } from "./history-store"
+import type { FollowupDraft } from "./submit"
+
+export type PromptInputState = ReturnType<typeof usePrompt>
+
+export type PromptInputSubmission = {
+  abort: () => Promise<void> | void
+  handleSubmit: (event: Event) => Promise<void> | void
+}
+
+export type PromptInputControls = {
+  agents: {
+    available: { name: string; hidden?: boolean; mode: string }[]
+    options: string[]
+    current: string
+    loading: boolean
+    visible: boolean
+    select: (name: string | undefined) => void
+  }
+  model: {
+    selection: ReturnType<typeof useLocal>["model"]
+    paid: boolean
+    loading: boolean
+  }
+  session: {
+    id?: string
+    tabs: {
+      active: () => string | undefined
+      all: () => string[]
+      open: (tab: string) => void | Promise<void>
+      setActive: (tab: string) => void
+    }
+    reviewPanel: {
+      opened: () => boolean
+      open: () => void
+    }
+  }
+}
+
+export interface PromptInputProps {
+  class?: string
+  state?: PromptInputState
+  history?: PromptInputHistory
+  submission?: PromptInputSubmission
+  controls: PromptInputControls
+  ref?: (el: HTMLDivElement) => void
+  newSessionWorktree?: string
+  onNewSessionWorktreeReset?: () => void
+  edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
+  onEditLoaded?: () => void
+  shouldQueue?: () => boolean
+  onQueue?: (draft: FollowupDraft) => void
+  onAbort?: () => void
+  onSubmit?: () => void
+}

+ 47 - 0
packages/app/src/components/prompt-input/history-store.ts

@@ -0,0 +1,47 @@
+import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
+import type { Prompt } from "@/context/prompt"
+import { Persist, persisted } from "@/utils/persist"
+import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
+
+export type PromptInputHistory = {
+  entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
+  add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
+}
+
+type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
+
+function createPromptInputHistoryStore(
+  normal: Store<PromptHistoryState>,
+  setNormal: SetStoreFunction<PromptHistoryState>,
+  shell: Store<PromptHistoryState>,
+  setShell: SetStoreFunction<PromptHistoryState>,
+): PromptInputHistory {
+  return {
+    entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
+    add(prompt, mode, comments) {
+      const current = mode === "shell" ? shell : normal
+      const setCurrent = mode === "shell" ? setShell : setNormal
+      const next = prependHistoryEntry(current.entries, prompt, comments)
+      if (next === current.entries) return
+      setCurrent("entries", next)
+    },
+  }
+}
+
+export function createPromptInputHistory(): PromptInputHistory {
+  const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
+  const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
+  return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
+}
+
+export function createPersistedPromptInputHistory() {
+  const [normal, setNormal] = persisted(
+    Persist.global("prompt-history", ["prompt-history.v1"]),
+    createStore<PromptHistoryState>({ entries: [] }),
+  )
+  const [shell, setShell] = persisted(
+    Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
+    createStore<PromptHistoryState>({ entries: [] }),
+  )
+  return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
+}

+ 5 - 0
packages/app/src/components/prompt-input/placeholder.ts

@@ -13,3 +13,8 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
   if (!input.suggest) return input.t("prompt.placeholder.simple")
   return input.t("prompt.placeholder.normal", { example: input.example })
 }
+
+export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
+  if (mode === "shell") return placeholder
+  return "Ask anything, / for commands, @ for context..."
+}

+ 8 - 1
packages/app/src/components/prompt-input/submit.test.ts

@@ -1,5 +1,6 @@
 import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
-import type { Prompt } from "@/context/prompt"
+import { createStore } from "solid-js/store"
+import type { Prompt, PromptStore } from "@/context/prompt"
 import type { ModelSelection } from "@/context/local"
 
 let createPromptSubmit: typeof import("./submit").createPromptSubmit
@@ -31,7 +32,13 @@ let permissionServer = "server-a"
 let createSessionGate: Promise<void> | undefined
 
 const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
+const [promptStore, setPromptStore] = createStore<PromptStore>({
+  prompt: promptValue,
+  cursor: 0,
+  context: { items: [] },
+})
 const prompt = {
+  store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
   ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
   current: () => promptValue,
   cursor: () => 0,

+ 0 - 3
packages/app/src/components/prompt-input/transient-state.ts

@@ -12,7 +12,6 @@ export type PromptInputTransientState = {
   draggingType: "image" | "@mention" | null
   mode: "normal" | "shell"
   applyingHistory: boolean
-  variantOpen: boolean
 }
 
 function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
@@ -25,7 +24,6 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
     draggingType: null,
     mode: "normal",
     applyingHistory: false,
-    variantOpen: false,
   })
 }
 
@@ -40,7 +38,6 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
     draggingType: null,
     mode: "normal",
     applyingHistory: false,
-    variantOpen: false,
   })
 
   createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))

+ 5 - 3
packages/app/src/components/prompt-project-selector.tsx

@@ -55,7 +55,7 @@ export function createPromptProjectController(input: {
   const [store, setStore] = createStore({ open: false, search: "", active: "" })
   let searchRef: HTMLInputElement | undefined
 
-  const selected = () => {
+  const current = () => {
     const key = pathKey(input.controls().directory)
     return input
       .controls()
@@ -65,6 +65,7 @@ export function createPromptProjectController(input: {
           (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
       )
   }
+  const selected = () => current() ?? input.controls().available[0]
   const projects = () => {
     const search = store.search.trim().toLowerCase()
     if (!search) return input.controls().available
@@ -100,8 +101,8 @@ export function createPromptProjectController(input: {
   }
   const select = (project: PromptProject) => {
     if (
-      pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
-      project.server?.key !== selected()?.server?.key
+      pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") ||
+      project.server?.key !== current()?.server?.key
     ) {
       input.controls().select(project.worktree, project.server?.key)
     }
@@ -124,6 +125,7 @@ export function createPromptProjectController(input: {
 
   return {
     selected,
+    empty: () => input.controls().available.length === 0,
     projects,
     servers,
     projectKey,

+ 30 - 18
packages/app/src/components/prompt-workspace-selector.tsx

@@ -93,24 +93,36 @@ export function PromptWorkspaceSelector(props: {
           </MenuV2.Content>
         </MenuV2.Portal>
       </MenuV2>
-      <Show when={props.branch}>
-        {(branch) => (
-          <>
-            <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
-            <TooltipV2
-              placement="top"
-              value={branch()}
-              class="min-w-0 max-w-[220px]"
-              contentClass="max-w-[calc(100vw-32px)] break-all"
-            >
-              <div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
-                <Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
-                <span class="min-w-0 truncate">{branch()}</span>
-              </div>
-            </TooltipV2>
-          </>
-        )}
-      </Show>
+      <PromptGitStatus branch={props.branch} />
     </>
   )
 }
+
+export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
+  const language = useLanguage()
+  const label = () => {
+    if (props.noGit) return language.t("session.new.git.none")
+    return props.branch
+  }
+
+  return (
+    <Show when={label()}>
+      {(value) => (
+        <>
+          <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
+          <TooltipV2
+            placement="top"
+            value={value()}
+            class="min-w-0 max-w-[220px]"
+            contentClass="max-w-[calc(100vw-32px)] break-all"
+          >
+            <div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
+              <Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
+              <span class="min-w-0 truncate">{value()}</span>
+            </div>
+          </TooltipV2>
+        </>
+      )}
+    </Show>
+  )
+}

+ 13 - 5
packages/app/src/components/session/session-sortable-tab-v2.tsx

@@ -2,7 +2,8 @@ import { createMemo, Show } from "solid-js"
 import type { JSX } from "solid-js"
 import { useSortable } from "@dnd-kit/solid/sortable"
 import { IconButton } from "@opencode-ai/ui/icon-button"
-import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
@@ -19,6 +20,7 @@ export function SortableTabV2(props: {
   const file = useFile()
   const language = useLanguage()
   const command = useCommand()
+  const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
   const sortable = useSortable({
     get id() {
       return props.tab
@@ -39,9 +41,15 @@ export function SortableTabV2(props: {
         <Tabs.Trigger
           value={props.tab}
           closeButton={
-            <TooltipKeybind
-              title={language.t("common.closeTab")}
-              keybind={command.keybind("tab.close")}
+            <TooltipV2
+              value={
+                <>
+                  {language.t("common.closeTab")}
+                  <Show when={closeTabKeybind().length > 0}>
+                    <KeybindV2 keys={closeTabKeybind()} variant="neutral" />
+                  </Show>
+                </>
+              }
               placement="bottom"
               gutter={10}
             >
@@ -52,7 +60,7 @@ export function SortableTabV2(props: {
                 onClick={() => props.onTabClose(props.tab)}
                 aria-label={language.t("common.closeTab")}
               />
-            </TooltipKeybind>
+            </TooltipV2>
           }
           hideCloseButton
           onMiddleClick={() => props.onTabClose(props.tab)}

+ 4 - 3
packages/app/src/components/settings-v2/settings-v2.css

@@ -133,9 +133,10 @@
 }
 
 [data-slot="settings-v2-row-description"] {
+  margin-block: -3.5px;
   font-size: 13px;
   font-weight: 440;
-  line-height: 1;
+  line-height: 20px;
   color: var(--v2-text-text-muted);
 }
 
@@ -270,10 +271,10 @@
 }
 
 .settings-v2-provider-description {
-  margin: 0;
+  margin-block: -3.5px;
   font-size: 13px;
   font-weight: 440;
-  line-height: 1;
+  line-height: 20px;
   color: var(--v2-text-text-muted);
 }
 

+ 10 - 1
packages/app/src/components/terminal.tsx

@@ -292,7 +292,12 @@ export const Terminal = (props: TerminalProps) => {
 
   const scheduleSize = (cols: number, rows: number) => {
     if (disposed) return
-    if (lastSize?.cols === cols && lastSize?.rows === rows) return
+    if (lastSize?.cols === cols && lastSize?.rows === rows) {
+      pendingSize = undefined
+      if (sizeTimer !== undefined) clearTimeout(sizeTimer)
+      sizeTimer = undefined
+      return
+    }
 
     pendingSize = { cols, rows }
 
@@ -317,8 +322,10 @@ export const Terminal = (props: TerminalProps) => {
 
   createEffect(() => {
     const colors = terminalColors()
+    const mode = theme.mode() === "dark" ? "dark" : "light"
     if (!term) return
     setOptionIfSupported(term, "theme", colors)
+    setOptionIfSupported(term, "colorScheme", mode)
   })
 
   createEffect(() => {
@@ -396,6 +403,7 @@ export const Terminal = (props: TerminalProps) => {
       }
       _ghostty = g
       term = t
+      setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light")
       output = terminalWriter((data, done) =>
         t.write(data, () => {
           done?.()
@@ -595,6 +603,7 @@ export const Terminal = (props: TerminalProps) => {
           tries = 0
           local.onConnect?.()
           scheduleSize(t.cols, t.rows)
+          if (t.getMode(2031)) t.write("\x1b[?996n")
         }
 
         const handleMessage = (event: MessageEvent) => {

+ 21 - 6
packages/app/src/components/titlebar.tsx

@@ -67,7 +67,7 @@ export function useTitlebarRightMount() {
   return mount
 }
 
-export function Titlebar(props: { update?: TitlebarUpdate }) {
+export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
   const layout = useLayout()
   const platform = usePlatform()
   const command = useCommand()
@@ -462,7 +462,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                   "md:pl-4": !mac(),
                 }}
               >
-                <ChannelIndicator />
+                <ChannelIndicator debugTools={props.debugTools} />
                 <Show when={windows() || linux()}>
                   <WindowsAppMenu command={command} platform={platform} variant="v2" />
                 </Show>
@@ -660,9 +660,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                       </div>
                     </Show>
                     <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
-                    <ChannelIndicator />
                   </div>
                 </div>
+                <ChannelIndicator debugTools={props.debugTools} />
               </div>
             </div>
 
@@ -747,12 +747,27 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
   )
 }
 
-function ChannelIndicator() {
+function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
+  const channel = import.meta.env.VITE_OPENCODE_CHANNEL
+  if (channel === "dev" && props.debugTools) {
+    return (
+      <button
+        type="button"
+        class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
+        onClick={props.debugTools.toggle}
+        aria-label="Toggle debug tools"
+        aria-pressed={props.debugTools.visible}
+      >
+        DEV
+      </button>
+    )
+  }
+
   return (
     <>
-      {["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
+      {["beta", "dev"].includes(channel) && (
         <div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
-          {import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
+          {channel.toUpperCase()}
         </div>
       )}
     </>

+ 8 - 1
packages/app/src/context/notification.tsx

@@ -176,7 +176,14 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
 
     onCleanup(() => states.forEach((value) => value.dispose()))
 
-    const selected = () => ensure(activeServer())
+    const selected = () => {
+      const list = global.servers.list()
+      const key = activeServer()
+      if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key)
+      const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
+      if (!conn) throw new Error("Notification server not found")
+      return ensure(ServerConnection.key(conn))
+    }
 
     return {
       ready: () => selected().ready(),

+ 3 - 0
packages/app/src/context/platform.tsx

@@ -115,6 +115,9 @@ type PlatformBase = {
   /** Export collected diagnostic logs (desktop only) */
   exportDebugLogs?(): Promise<string>
 
+  /** Force focus styles on interactive elements through desktop devtools (desktop only) */
+  setForceFocus?(enabled: boolean): Promise<void>
+
   /** Record a fatal renderer error in platform logs (desktop only) */
   recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
 }

+ 2 - 1
packages/app/src/context/prompt-state.ts

@@ -64,7 +64,7 @@ export type PromptScope = { draftID: string } | { dir: string; id?: string }
 
 export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
 
-type PromptStore = {
+export type PromptStore = {
   prompt: Prompt
   cursor?: number
   model?: PromptModel
@@ -189,6 +189,7 @@ function promptStore(initial?: InitialPrompt): PromptStore {
 function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
   const actions = createPromptActions(setStore)
   const value = {
+    store: [() => store, setStore] as [Accessor<PromptStore>, SetStoreFunction<PromptStore>],
     current: () => store.prompt,
     cursor: createMemo(() => store.cursor),
     dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),

+ 18 - 6
packages/app/src/context/prompt.tsx

@@ -1,7 +1,7 @@
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useParams, useSearchParams } from "@solidjs/router"
-import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
+import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
 import { requireServerKey } from "@/utils/session-route"
 import { ServerConnection } from "./server"
 import { useServerSDK } from "./server-sdk"
@@ -36,6 +36,7 @@ export type {
   ImageAttachmentPart,
   Prompt,
   PromptModel,
+  PromptStore,
   PromptScope,
   PromptSession,
   TextPart,
@@ -132,18 +133,29 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
     const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
     const ready = createPromptReady(session)
 
+    const withSuspense = <T,>(cb: () => T): (() => T) =>
+      createResource(
+        async () => {
+          const value = cb()
+          await session().ready.promise
+          return value
+        },
+        cb,
+        { initialValue: cb() },
+      )[0]
+
     return {
       ready,
       capture: (scope?: PromptScope) => pick(scope).capture(),
-      current: () => session().current(),
-      cursor: () => session().cursor(),
-      dirty: () => session().dirty(),
+      current: withSuspense(() => session().current()),
+      cursor: withSuspense(() => session().cursor()),
+      dirty: withSuspense(() => session().dirty()),
       model: {
-        current: () => session().model.current(),
+        current: withSuspense(() => session().model.current()),
         set: (model: PromptModel | undefined) => session().model.set(model),
       },
       context: {
-        items: () => session().context.items(),
+        items: withSuspense(() => session().context.items()),
         add: (item: ContextItem) => session().context.add(item),
         remove: (key: string) => session().context.remove(key),
         removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),

+ 43 - 0
packages/app/src/context/server-session.test.ts

@@ -199,6 +199,49 @@ describe("server session", () => {
     expect(store.history.more("child")).toBe(true)
   })
 
+  test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
+    const missing = Promise.withResolvers<SingleMessageResponse>()
+    const assistant = assistantMessage("message-2", "message-missing")
+    const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise])
+    const store = createServerSession(client)
+    const loading = store.sync("child")
+    await client.rootRequested(1)
+
+    missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } }))
+    await loading
+
+    expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
+    expect(store.data.message.child).toEqual([assistant])
+    expect(store.history.more("child")).toBe(true)
+  })
+
+  test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
+    const missing = Promise.withResolvers<SingleMessageResponse>()
+    const parent = userMessage("message-1")
+    const part = textPart(parent.id)
+    const assistant = assistantMessage("message-2", parent.id)
+    const client = rootMessageClient(
+      [
+        response([
+          { info: parent, parts: [part] },
+          { info: assistant, parts: [] },
+        ]),
+        response([{ info: assistant, parts: [] }], "older"),
+      ],
+      [missing.promise],
+    )
+    const store = createServerSession(client)
+    await store.sync("child")
+    const loading = store.sync("child", { force: true })
+    await client.rootRequested(1)
+
+    missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } }))
+    await loading
+
+    expect(store.data.message.child).toEqual([assistant])
+    expect(store.data.part[parent.id]).toBeUndefined()
+  })
+
   test("does not let an optimistic user suppress initial root backfill", async () => {
     const user = userMessage("message-1")
     const part = textPart(user.id)

+ 12 - 1
packages/app/src/context/server-session.ts

@@ -109,6 +109,7 @@ function reconcileFetched<T extends { id: string }>(
   options: {
     touched?: ReadonlySet<string>
     retained?: ReadonlySet<string>
+    removed?: ReadonlySet<string>
     preserveUnfetched?: boolean | ((item: T) => boolean)
   } = {},
 ) {
@@ -131,6 +132,7 @@ function reconcileFetched<T extends { id: string }>(
     if (item) result.set(id, item)
     if (!item) result.delete(id)
   }
+  for (const id of options.removed ?? emptyIDs) result.delete(id)
   return [...result.values()].sort((a, b) => cmp(a.id, b.id))
 }
 
@@ -570,6 +572,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
       touched: touchedMessages,
       retained: load?.retainedMessages,
+      removed: load?.removedMessages,
       preserveUnfetched,
     })
     batch(() => {
@@ -638,7 +641,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
           if (generations.get(sessionID) !== active) break
           const parent = await fetchMessage(sessionID, parentID, () =>
             resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)),
-          )
+          ).catch((error) => {
+            const cause = error instanceof Error && typeof error.cause === "object" ? error.cause : undefined
+            if (cause && "status" in cause && cause.status === 404) {
+              load.removedMessages.add(parentID)
+              return
+            }
+            throw error
+          })
+          if (!parent) continue
           if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`)
           parents.push(parent)
         }

+ 1 - 0
packages/app/src/i18n/ar.ts

@@ -610,6 +610,7 @@ export const dict = {
   "session.new.workspace.triggerLocal": "محلي",
   "session.new.workspace.local": "المستودع المحلي",
   "session.new.workspace.existing": "مساحة عمل…",
+  "session.new.git.none": "لا يوجد Git",
   "session.new.lastModified": "آخر تعديل",
   "session.header.search.placeholder": "بحث {{project}}",
   "session.header.searchFiles": "بحث عن الملفات",

+ 1 - 0
packages/app/src/i18n/br.ts

@@ -616,6 +616,7 @@ export const dict = {
   "session.new.workspace.triggerLocal": "Local",
   "session.new.workspace.local": "Repositório local",
   "session.new.workspace.existing": "Espaço de trabalho…",
+  "session.new.git.none": "Sem Git",
   "session.new.lastModified": "Última modificação",
   "session.header.search.placeholder": "Buscar {{project}}",
   "session.header.searchFiles": "Buscar arquivos",

+ 1 - 0
packages/app/src/i18n/bs.ts

@@ -672,6 +672,7 @@ export const dict = {
   "session.new.workspace.triggerLocal": "Lokalno",
   "session.new.workspace.local": "Lokalni repozitorij",
   "session.new.workspace.existing": "Radni prostor…",
+  "session.new.git.none": "Nema Gita",
   "session.new.lastModified": "Posljednja izmjena",
 
   "session.header.search.placeholder": "Pretraži {{project}}",

+ 1 - 0
packages/app/src/i18n/da.ts

@@ -667,6 +667,7 @@ export const dict = {
   "session.new.workspace.triggerLocal": "Lokal",
   "session.new.workspace.local": "Lokalt repository",
   "session.new.workspace.existing": "Arbejdsområde…",
+  "session.new.git.none": "Ingen Git",
   "session.new.lastModified": "Sidst ændret",
 
   "session.header.search.placeholder": "Søg {{project}}",

+ 1 - 0
packages/app/src/i18n/de.ts

@@ -625,6 +625,7 @@ export const dict = {
   "session.new.workspace.triggerLocal": "Lokal",
   "session.new.workspace.local": "Lokales Repository",
   "session.new.workspace.existing": "Arbeitsbereich…",
+  "session.new.git.none": "Kein Git",
   "session.new.lastModified": "Zuletzt geändert",
   "session.header.search.placeholder": "{{project}} durchsuchen",
   "session.header.searchFiles": "Dateien suchen",

+ 3 - 0
packages/app/src/i18n/en.ts

@@ -102,6 +102,7 @@ export const dict = {
   "dialog.provider.empty": "No providers found",
   "dialog.provider.group.popular": "Popular",
   "dialog.provider.group.other": "Other",
+  "dialog.provider.custom.label": "Custom OpenAI-compatible provider",
   "dialog.provider.tag.recommended": "Recommended",
   "dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
   "dialog.provider.opencode.tagline": "Reliable optimized models",
@@ -122,6 +123,7 @@ export const dict = {
 
   "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
   "dialog.model.unpaid.addMore.title": "Add more models from popular providers",
+  "dialog.model.unpaid.viewMoreProviders": "See 70+ more providers",
 
   "dialog.provider.viewAll": "Show more providers",
 
@@ -694,6 +696,7 @@ export const dict = {
   "session.new.workspace.triggerLocal": "Local",
   "session.new.workspace.local": "Local repository",
   "session.new.workspace.existing": "Workspace…",
+  "session.new.git.none": "No Git",
   "session.new.lastModified": "Last modified",
 
   "session.header.search.placeholder": "Search {{project}}",

+ 1 - 0
packages/app/src/i18n/es.ts

@@ -673,6 +673,7 @@ export const dict = {
   "session.new.workspace.triggerLocal": "Local",
   "session.new.workspace.local": "Repositorio local",
   "session.new.workspace.existing": "Espacio de trabajo…",
+  "session.new.git.none": "Sin Git",
   "session.new.lastModified": "Última modificación",
 
   "session.header.search.placeholder": "Buscar {{project}}",

+ 1 - 0
packages/app/src/i18n/fr.ts

@@ -621,6 +621,7 @@ export const dict = {
   "session.new.workspace.triggerLocal": "Local",
   "session.new.workspace.local": "Dépôt local",
   "session.new.workspace.existing": "Espace de travail…",
+  "session.new.git.none": "Pas de Git",
   "session.new.lastModified": "Dernière modification",
   "session.header.search.placeholder": "Rechercher {{project}}",
   "session.header.searchFiles": "Rechercher des fichiers",

+ 1 - 0
packages/app/src/i18n/ja.ts

@@ -612,6 +612,7 @@ export const dict = {
   "session.new.workspace.triggerLocal": "ローカル",
   "session.new.workspace.local": "ローカルリポジトリ",
   "session.new.workspace.existing": "ワークスペース…",
+  "session.new.git.none": "Git なし",
   "session.new.lastModified": "最終更新",
   "session.header.search.placeholder": "{{project}}を検索",
   "session.header.searchFiles": "ファイルを検索",

+ 1 - 0
packages/app/src/i18n/ko.ts

@@ -970,6 +970,7 @@ export const dict = {
   "session.new.workspace.triggerLocal": "로컬",
   "session.new.workspace.local": "로컬 저장소",
   "session.new.workspace.existing": "작업 공간…",
+  "session.new.git.none": "Git 없음",
 
   "sidebar.empty.title": "열린 프로젝트 없음",
   "sidebar.empty.description": "프로젝트를 열어 시작하세요",

+ 1 - 0
packages/app/src/i18n/no.ts

@@ -1064,6 +1064,7 @@ export const dict = {
   "session.new.workspace.triggerLocal": "Lokalt",
   "session.new.workspace.local": "Lokalt depot",
   "session.new.workspace.existing": "Arbeidsområde…",
+  "session.new.git.none": "Ingen Git",
 
   "sidebar.empty.title": "Ingen åpne prosjekter",
   "sidebar.empty.description": "Åpne et prosjekt for å komme i gang",

+ 1 - 0
packages/app/src/i18n/pl.ts

@@ -616,6 +616,7 @@ export const dict = {
   "session.new.workspace.triggerLocal": "Lokalnie",
   "session.new.workspace.local": "Lokalne repozytorium",
   "session.new.workspace.existing": "Przestrzeń robocza…",
+  "session.new.git.none": "Brak Git",
   "session.new.lastModified": "Ostatnio zmodyfikowano",
   "session.header.search.placeholder": "Szukaj {{project}}",
   "session.header.searchFiles": "Szukaj plików",

Некоторые файлы не были показаны из-за большого количества измененных файлов