Преглед изворни кода

chore: merge v2 into service channel config

Dax Raad пре 3 недеља
родитељ
комит
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",

Неке датотеке нису приказане због велике количине промена