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

feat(session-ui): rewrite v2 prompt input (#37102)

Brendan Allan 1 месяц назад
Родитель
Сommit
c0a258b22a
35 измененных файлов с 3609 добавлено и 1083 удалено
  1. 1 0
      bun.lock
  2. 3 6
      packages/app/e2e/regression/prompt-thinking-level.spec.ts
  3. 1 1
      packages/app/e2e/smoke/session-timeline.spec.ts
  4. 11 1
      packages/app/src/app.tsx
  5. 585 0
      packages/app/src/components/prompt-input-v2.tsx
  6. 9 3
      packages/app/src/components/prompt-input.stories.tsx
  7. 300 889
      packages/app/src/components/prompt-input.tsx
  8. 1 1
      packages/app/src/components/prompt-input/attachments.ts
  9. 57 0
      packages/app/src/components/prompt-input/contracts.ts
  10. 51 0
      packages/app/src/components/prompt-input/history-store.ts
  11. 5 0
      packages/app/src/components/prompt-input/placeholder.ts
  12. 8 1
      packages/app/src/components/prompt-input/submit.test.ts
  13. 0 3
      packages/app/src/components/prompt-input/transient-state.ts
  14. 5 3
      packages/app/src/components/prompt-project-selector.tsx
  15. 2 1
      packages/app/src/context/prompt-state.ts
  16. 18 6
      packages/app/src/context/prompt.tsx
  17. 72 81
      packages/app/src/pages/new-session.tsx
  18. 117 78
      packages/app/src/pages/session.tsx
  19. 1 4
      packages/app/src/pages/session/composer/session-composer-controls.ts
  20. 0 1
      packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx
  21. 0 2
      packages/app/test-browser/prompt-transient-state.test.ts
  22. 5 1
      packages/session-ui/package.json
  23. 267 0
      packages/session-ui/src/v2/components/prompt-input/attachments.ts
  24. 686 0
      packages/session-ui/src/v2/components/prompt-input/index.tsx
  25. 463 0
      packages/session-ui/src/v2/components/prompt-input/interaction.ts
  26. 133 0
      packages/session-ui/src/v2/components/prompt-input/machine.test.ts
  27. 266 0
      packages/session-ui/src/v2/components/prompt-input/machine.ts
  28. 221 0
      packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx
  29. 94 0
      packages/session-ui/src/v2/components/prompt-input/store.test.ts
  30. 114 0
      packages/session-ui/src/v2/components/prompt-input/store.ts
  31. 106 0
      packages/session-ui/src/v2/components/prompt-input/types.ts
  32. 3 0
      packages/storybook/.storybook/mocks/app/context/command.ts
  33. 1 1
      packages/storybook/.storybook/mocks/app/context/language.ts
  34. 2 0
      packages/storybook/.storybook/mocks/app/context/sync.ts
  35. 1 0
      packages/storybook/package.json

+ 1 - 0
bun.lock

@@ -937,6 +937,7 @@
         "@types/node": "catalog:",
         "@types/react": "18.0.25",
         "react": "18.2.0",
+        "react-dom": "18.2.0",
         "solid-js": "catalog:",
         "storybook": "^10.2.13",
         "storybook-solidjs-vite": "^10.0.9",

+ 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.locator('button[title="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" }))
 }

+ 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"

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

@@ -0,0 +1,585 @@
+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 { Prompt, 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
+  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}
+        class={props.class}
+        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,
+            }
+          : 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),
+      },
+      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())
+}

+ 9 - 3
packages/app/src/components/prompt-input.stories.tsx

@@ -30,8 +30,16 @@ function PromptInputExample() {
     activeTab: undefined as string | undefined,
     reviewOpen: false,
   })
+  const storyModel = {
+    id: "claude-3-7-sonnet",
+    name: "Claude 3.7 Sonnet",
+    provider: { id: "anthropic", name: "Anthropic" },
+  }
   const model = {
-    current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
+    current: () => storyModel,
+    list: () => [storyModel],
+    visible: () => true,
+    set: () => {},
     variant: {
       list: () => ["fast", "thinking"],
       current: () => controls.variant,
@@ -67,7 +75,6 @@ function PromptInputExample() {
         open: () => setControls("reviewOpen", true),
       },
     },
-    newLayoutDesigns: true,
   }
   const addReviewComment = () => {
     const comment = controls.comments + 1
@@ -146,7 +153,6 @@ function PromptInputWithOpenDock() {
       },
       reviewPanel: { opened: () => false, open: () => {} },
     },
-    newLayoutDesigns: true,
   }
   const state = {
     blocked: () => false,

+ 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
+        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,
+        }}
+      >
+        <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

+ 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
+}

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

@@ -0,0 +1,51 @@
+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,

+ 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),

+ 72 - 81
packages/app/src/pages/new-session.tsx

@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { NewSessionDesignView } from "@/components/session"
-import { PromptInput } from "@/components/prompt-input"
+import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
 import { StatusPopoverV2 } from "@/components/status-popover"
 import {
   PromptProjectAddButton,
@@ -68,8 +68,6 @@ export default function NewSessionPage() {
 
   useComposerCommands({ model })
 
-  let inputRef: HTMLDivElement | undefined
-
   const inputController = createPromptInputController({
     sessionKey: route.sessionKey,
     sessionID: () => route.params.id,
@@ -77,9 +75,38 @@ export default function NewSessionPage() {
     model,
   })
   const projectControls = createPromptProjectControls()
+
+  const [store, setStore] = createStore<{ worktree?: string }>({})
+  const rightMount = useTitlebarRightMount()
+
+  const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
+  const newSessionWorktree = createMemo(() => {
+    if (!showWorkspaceBar()) return "main"
+    if (store.worktree) return store.worktree
+    const project = sync().project
+    if (project && sdk().directory !== project.worktree) return sdk().directory
+    return "main"
+  })
+  const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
+  const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
+  const selectedBranch = createMemo(() => {
+    const worktree = newSessionWorktree()
+    if (worktree === "main" || worktree === "create") return localBranch()
+    return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
+  })
+  const promptInputV2Controller = usePromptInputV2Controller({
+    get controls() {
+      return inputController()
+    },
+    get newSessionWorktree() {
+      return newSessionWorktree()
+    },
+    onNewSessionWorktreeReset: () => setStore("worktree", undefined),
+    onSubmit: () => comments.clear(),
+  })
   const projectController = createPromptProjectController({
     controls: projectControls,
-    onDone: () => inputRef?.focus(),
+    onDone: promptInputV2Controller.restoreFocus,
   })
 
   command.register("new-session", () => [
@@ -97,29 +124,10 @@ export default function NewSessionPage() {
       title: language.t("command.input.focus"),
       category: language.t("command.category.view"),
       keybind: "ctrl+l",
-      onSelect: () => inputRef?.focus(),
+      onSelect: () => promptInputV2Controller.restoreFocus(),
     },
   ])
 
-  const [store, setStore] = createStore<{ worktree?: string }>({})
-  const rightMount = useTitlebarRightMount()
-
-  const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
-  const newSessionWorktree = createMemo(() => {
-    if (!showWorkspaceBar()) return "main"
-    if (store.worktree) return store.worktree
-    const project = sync().project
-    if (project && sdk().directory !== project.worktree) return sdk().directory
-    return "main"
-  })
-  const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
-  const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
-  const selectedBranch = createMemo(() => {
-    const worktree = newSessionWorktree()
-    if (worktree === "main" || worktree === "create") return localBranch()
-    return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
-  })
-
   createEffect(() => {
     if (!prompt.ready()) return
     untrack(() => {
@@ -132,16 +140,18 @@ export default function NewSessionPage() {
 
   createEffect(() => {
     if (!prompt.ready()) return
-    requestAnimationFrame(() => inputRef?.focus())
+    promptInputV2Controller.restoreFocus()
   })
+
   const ready = Promise.resolve()
-  const [promptReady] = createResource(
+  const [suspendUntilPromptReady] = createResource(
     () => prompt.ready.promise ?? ready,
     (promise) => promise.then(() => true),
   )
 
   return (
     <div class="relative size-full overflow-hidden flex flex-col">
+      {suspendUntilPromptReady()}
       <Show when={rightMount()}>
         {(mount) => (
           <Portal mount={mount()}>
@@ -158,63 +168,44 @@ export default function NewSessionPage() {
           <div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
             <NewSessionDesignView>
               <div class={NEW_SESSION_CONTENT_WIDTH}>
-                <Show
-                  when={prompt.ready() || promptReady()}
-                  fallback={
-                    <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak pointer-events-none">
-                      {language.t("prompt.loading")}
-                    </div>
-                  }
-                >
-                  <div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar(), "gap-3": !showWorkspaceBar() }}>
-                    <PromptInput
-                      controls={inputController()}
-                      variant="new-session"
-                      ref={(el) => {
-                        inputRef = el
+                <div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar(), "gap-3": !showWorkspaceBar() }}>
+                  <PromptInputV2Composer controller={promptInputV2Controller} />
+                  <Show when={projectController.empty()}>
+                    <PromptProjectAddButton controller={projectController} />
+                  </Show>
+                  <Show when={projectController.selected()}>
+                    <div
+                      class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
+                      classList={{
+                        "flex-col justify-center sm:flex-row": showWorkspaceBar(),
+                        "justify-start": !showWorkspaceBar(),
                       }}
-                      newSessionWorktree={newSessionWorktree()}
-                      onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
-                      onSubmit={() => comments.clear()}
-                      toolbar={
-                        <Show when={!projectController.selected()}>
-                          <PromptProjectAddButton controller={projectController} />
-                        </Show>
-                      }
-                    />
-                    <Show when={projectController.selected()}>
-                      <div
-                        class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
-                        classList={{
-                          "flex-col justify-center sm:flex-row": showWorkspaceBar(),
-                          "justify-start": !showWorkspaceBar(),
-                        }}
-                      >
-                        <PromptProjectSelector
-                          controller={projectController}
-                          placement={showWorkspaceBar() ? "bottom" : "bottom-start"}
+                    >
+                      <PromptProjectSelector
+                        controller={projectController}
+                        placement={showWorkspaceBar() ? "bottom" : "bottom-start"}
+                      />
+                      <Show when={showWorkspaceBar()}>
+                        <PromptWorkspaceSelector
+                          value={newSessionWorktree()}
+                          projectRoot={projectRoot()}
+                          workspaces={sync().project?.sandboxes ?? []}
+                          branch={selectedBranch()}
+                          onChange={(value) =>
+                            setStore(
+                              "worktree",
+                              value === "main" && sync().project?.worktree !== sdk().directory
+                                ? sync().project?.worktree
+                                : value,
+                            )
+                          }
+                          onDone={promptInputV2Controller.restoreFocus}
                         />
-                        <Show when={showWorkspaceBar()}>
-                          <PromptWorkspaceSelector
-                            value={newSessionWorktree()}
-                            projectRoot={projectRoot()}
-                            workspaces={sync().project?.sandboxes ?? []}
-                            branch={selectedBranch()}
-                            onChange={(value) =>
-                              setStore(
-                                "worktree",
-                                value === "main" && sync().project?.worktree !== sdk().directory
-                                  ? sync().project?.worktree
-                                  : value,
-                              )
-                            }
-                            onDone={() => inputRef?.focus()}
-                          />
-                        </Show>
-                      </div>
-                    </Show>
-                  </div>
-                </Show>
+                      </Show>
+                    </div>
+                  </Show>
+                </div>
+                {/*</Show>*/}
               </div>
             </NewSessionDesignView>
             <ProviderTip

+ 117 - 78
packages/app/src/pages/session.tsx

@@ -58,6 +58,7 @@ import { useSync } from "@/context/sync"
 import { useTabs } from "@/context/tabs"
 import { TerminalProvider, useTerminal } from "@/context/terminal"
 import { PromptInput } from "@/components/prompt-input"
+import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
 import { useSettingsCommand } from "@/components/settings-dialog"
 import { setCursorPosition } from "@/components/prompt-input/editor-dom"
 import { promptLength } from "@/components/prompt-input/history"
@@ -2045,83 +2046,6 @@ export default function Page() {
 
   useUsageExceededDialogs()
 
-  const composerRegion = () => {
-    const controller = createSessionComposerRegionController({
-      state: composer,
-      sessionKey,
-      sessionID: () => params.id,
-      prompt,
-      ready: () => !store.deferRender && messagesReady(),
-      centered,
-      todo: {
-        collapsed: () => view().todoCollapsed.get(),
-        onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
-      },
-      followup: () =>
-        params.id && !isChildSession()
-          ? {
-              items: followupDock(),
-              sending: sendingFollowup(),
-              onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
-              onEdit: editFollowup,
-            }
-          : undefined,
-      revert: () =>
-        rolled().length > 0
-          ? {
-              items: rolled(),
-              restoring: restoring(),
-              disabled: reverting(),
-              onRestore: restore,
-            }
-          : undefined,
-      onResponseSubmit: resumeScroll,
-      openParent: () => {
-        const id = info()?.parentID
-        if (!id) return
-        navigate(
-          params.serverKey
-            ? sessionHref(requireServerKey(params.serverKey), id)
-            : legacySessionHref(sdk().directory, id),
-        )
-      },
-      setPromptRef: (el) => {
-        inputRef = el
-      },
-      setDockRef: (el) => {
-        promptDock = el
-      },
-    })
-    return (
-      <SessionComposerRegion
-        controller={controller}
-        promptInput={
-          <PromptInput
-            controls={inputController()}
-            ref={(el) => {
-              inputRef = el
-            }}
-            newSessionWorktree={newSessionWorktree()}
-            onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
-            onSubmit={() => {
-              comments.clear()
-              resumeScroll()
-            }}
-            edit={editingFollowup()}
-            onEditLoaded={clearFollowupEdit}
-            shouldQueue={queueEnabled}
-            onQueue={queueFollowup}
-            onAbort={() => {
-              const id = params.id
-              if (!id) return
-              setFollowup("paused", id, true)
-            }}
-          />
-        }
-      />
-    )
-  }
-
   const mobileTabs = (compact = false, bottom = false) => (
     <Tabs value={store.mobileTab} class="h-auto">
       <Tabs.List
@@ -2236,7 +2160,122 @@ export default function Page() {
         </Switch>
       </div>
 
-      <Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
+      <Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>
+        {(_) => {
+          const controller = createSessionComposerRegionController({
+            state: composer,
+            sessionKey,
+            sessionID: () => params.id,
+            prompt,
+            ready: () => !store.deferRender && messagesReady(),
+            centered,
+            todo: {
+              collapsed: () => view().todoCollapsed.get(),
+              onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
+            },
+            followup: () =>
+              params.id && !isChildSession()
+                ? {
+                    items: followupDock(),
+                    sending: sendingFollowup(),
+                    onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
+                    onEdit: editFollowup,
+                  }
+                : undefined,
+            revert: () =>
+              rolled().length > 0
+                ? {
+                    items: rolled(),
+                    restoring: restoring(),
+                    disabled: reverting(),
+                    onRestore: restore,
+                  }
+                : undefined,
+            onResponseSubmit: resumeScroll,
+            openParent: () => {
+              const id = info()?.parentID
+              if (!id) return
+              navigate(
+                params.serverKey
+                  ? sessionHref(requireServerKey(params.serverKey), id)
+                  : legacySessionHref(sdk().directory, id),
+              )
+            },
+            setPromptRef: (el) => {
+              inputRef = el
+            },
+            setDockRef: (el) => {
+              promptDock = el
+            },
+          })
+          return (
+            <SessionComposerRegion
+              controller={controller}
+              promptInput={
+                <Show
+                  when={newSessionDesign()}
+                  fallback={
+                    <PromptInput
+                      controls={inputController()}
+                      ref={(el) => {
+                        inputRef = el
+                      }}
+                      newSessionWorktree={newSessionWorktree()}
+                      onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
+                      onSubmit={() => {
+                        comments.clear()
+                        resumeScroll()
+                      }}
+                      edit={editingFollowup()}
+                      onEditLoaded={clearFollowupEdit}
+                      shouldQueue={queueEnabled}
+                      onQueue={queueFollowup}
+                      onAbort={() => {
+                        const id = params.id
+                        if (!id) return
+                        setFollowup("paused", id, true)
+                      }}
+                    />
+                  }
+                >
+                  {(_) => {
+                    const controller = usePromptInputV2Controller({
+                      get controls() {
+                        return inputController()
+                      },
+                      ref: (el) => {
+                        inputRef = el
+                      },
+                      get newSessionWorktree() {
+                        return newSessionWorktree()
+                      },
+                      onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
+                      onSubmit: () => {
+                        comments.clear()
+                        resumeScroll()
+                      },
+                      shouldQueue: queueEnabled,
+                      onQueue: queueFollowup,
+                      onAbort: () => {
+                        const id = params.id
+                        if (!id) return
+                        setFollowup("paused", id, true)
+                      },
+                    })
+                    return (
+                      <PromptInputV2Composer
+                        controller={controller}
+                        edit={editingFollowup()}
+                        onEditLoaded={clearFollowupEdit}
+                      />
+                    )
+                  }}
+                </Show>
+              }
+            />
+          )
+        }}
+      </Show>
       <Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
     </>
   )

+ 1 - 4
packages/app/src/pages/session/composer/session-composer-controls.ts

@@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
 import { createQuery } from "@tanstack/solid-query"
 import { useNavigate, useSearchParams } from "@solidjs/router"
 import { type Accessor, createMemo } from "solid-js"
-import type { PromptInputControls } from "@/components/prompt-input"
+import type { PromptInputControls } from "@/components/prompt-input/contracts"
 import type { PromptProjectControls } from "@/components/prompt-project-selector"
 import { useDirectoryPicker } from "@/components/directory-picker"
 import { useGlobal } from "@/context/global"
@@ -12,7 +12,6 @@ import type { QueryOptionsApi } from "@/context/server-sync"
 import { useServerSDK } from "@/context/server-sdk"
 import { serverName, ServerConnection, useServer } from "@/context/server"
 import { useSDK } from "@/context/sdk"
-import { useSettings } from "@/context/settings"
 import { useSync } from "@/context/sync"
 import { useTabs } from "@/context/tabs"
 import { useProviders } from "@/hooks/use-providers"
@@ -27,7 +26,6 @@ export function createPromptInputController(input: {
   const layout = useLayout()
   const local = useLocal()
   const providers = useProviders()
-  const settings = useSettings()
   const sync = useSync()
   const sdk = useSDK()
   const view = layout.view(input.sessionKey)
@@ -54,7 +52,6 @@ export function createPromptInputController(input: {
       tabs: layout.tabs(input.sessionKey),
       reviewPanel: view.reviewPanel,
     },
-    newLayoutDesigns: settings.general.newLayoutDesigns(),
   }))
 }
 

+ 0 - 1
packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx

@@ -71,7 +71,6 @@ const controls = {
     tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
     reviewPanel: { opened: () => false, open: () => {} },
   },
-  newLayoutDesigns: true,
 }
 
 const css = `

+ 0 - 2
packages/app/test-browser/prompt-transient-state.test.ts

@@ -18,7 +18,6 @@ test("resets transient prompt input state when the prompt session changes", () =
       draggingType: "image",
       mode: "shell",
       applyingHistory: true,
-      variantOpen: true,
     })
 
     setIdentity("B")
@@ -33,7 +32,6 @@ test("resets transient prompt input state when the prompt session changes", () =
       draggingType: null,
       mode: "normal",
       applyingHistory: false,
-      variantOpen: false,
     })
     dispose()
   })

+ 5 - 1
packages/session-ui/package.json

@@ -18,7 +18,11 @@
     "./context/*": "./src/context/*.tsx",
     "./styles": "./src/styles/index.css",
     "./v2/*.css": "./src/v2/components/*.css",
-    "./v2/*": "./src/v2/components/*.tsx"
+    "./v2/*": "./src/v2/components/*.tsx",
+    "./v2/prompt-input": "./src/v2/components/prompt-input/index.tsx",
+    "./v2/prompt-input/interaction": "./src/v2/components/prompt-input/interaction.ts",
+    "./v2/prompt-input/store": "./src/v2/components/prompt-input/store.ts",
+    "./v2/prompt-input/types": "./src/v2/components/prompt-input/types.ts"
   },
   "scripts": {
     "typecheck": "tsgo --noEmit",

+ 267 - 0
packages/session-ui/src/v2/components/prompt-input/attachments.ts

@@ -0,0 +1,267 @@
+import { onMount } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
+import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types"
+
+const accepted = [
+  "image/png",
+  "image/jpeg",
+  "image/gif",
+  "image/webp",
+  "application/pdf",
+  "text/*",
+  "application/json",
+  "application/ld+json",
+  "application/toml",
+  "application/x-toml",
+  "application/x-yaml",
+  "application/xml",
+  "application/yaml",
+  ".c",
+  ".cc",
+  ".cjs",
+  ".conf",
+  ".cpp",
+  ".css",
+  ".csv",
+  ".cts",
+  ".env",
+  ".go",
+  ".gql",
+  ".graphql",
+  ".h",
+  ".hh",
+  ".hpp",
+  ".htm",
+  ".html",
+  ".ini",
+  ".java",
+  ".js",
+  ".json",
+  ".jsx",
+  ".log",
+  ".md",
+  ".mdx",
+  ".mjs",
+  ".mts",
+  ".py",
+  ".rb",
+  ".rs",
+  ".sass",
+  ".scss",
+  ".sh",
+  ".sql",
+  ".toml",
+  ".ts",
+  ".tsx",
+  ".txt",
+  ".xml",
+  ".yaml",
+  ".yml",
+  ".zsh",
+]
+
+type PromptTarget = {
+  current: () => PromptInputV2Prompt
+  cursor: () => number | undefined
+  set: (prompt: PromptInputV2Prompt, cursor?: number) => void
+}
+
+export type PromptInputV2AttachmentConfig = {
+  picker?: (
+    options: { defaultPath?: string; multiple?: boolean; accept?: string[] },
+    onFile: (file: File) => Promise<unknown>,
+  ) => Promise<void>
+  directory: () => string
+  isDialogActive: () => boolean
+  warn: () => void
+  onError: (error: unknown) => void
+  readClipboardImage?: () => Promise<File | null>
+  getPathForFile?: (file: File) => string
+}
+
+export function createPromptInputV2Attachments(input: PromptInputV2AttachmentConfig & {
+  capture: () => PromptTarget
+  editor: () => HTMLElement | undefined
+  focusEditor: () => void
+  addPart: (part: PromptInputV2Prompt[number]) => boolean
+  setDraggingType: (type: "image" | "@mention" | null) => void
+}) {
+  const capture = () => {
+    const prompt = input.capture()
+    const editor = input.editor()
+    if (!editor) return
+    return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
+  }
+  const add = async (file: File, toast = true, target = capture()) => {
+    if (!target) return false
+    const mime = await attachmentMime(file)
+    if (!mime) {
+      if (toast) input.warn()
+      return false
+    }
+    const url = await dataUrl(file, mime)
+    if (!url) return false
+    const attachment: PromptInputV2Attachment = {
+      type: "image",
+      id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2),
+      filename: file.name,
+      sourcePath: input.getPathForFile?.(file) || undefined,
+      mime,
+      dataUrl: url,
+    }
+    target.prompt.set([...target.prompt.current(), attachment], target.cursor)
+    return true
+  }
+  const addAttachments = async (files: File[], toast = true, target = capture()) => {
+    const found = await files.reduce(
+      async (result, file) => {
+        const previous = await result
+        return (await add(file, false, target)) || previous
+      },
+      Promise.resolve(false),
+    )
+    if (!found && files.length > 0 && toast) input.warn()
+    return found
+  }
+  const handlePaste = async (event: ClipboardEvent) => {
+    const clipboardData = event.clipboardData
+    if (!clipboardData) return
+    const target = capture()
+    if (!target) return
+    event.preventDefault()
+    event.stopPropagation()
+    const files = Array.from(clipboardData.items).flatMap((item) => {
+      if (item.kind !== "file") return []
+      const file = item.getAsFile()
+      return file ? [file] : []
+    })
+    if (files.length > 0) {
+      await addAttachments(files, true, target)
+      return
+    }
+    const plainText = clipboardData.getData("text/plain") ?? ""
+    if (input.readClipboardImage && !plainText) {
+      const file = await input.readClipboardImage()
+      if (file && (await add(file, true, target))) return
+    }
+    if (!plainText) return
+    const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText
+    const put = () => {
+      if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
+      input.focusEditor()
+      return input.addPart({ type: "text", content: text, start: 0, end: 0 })
+    }
+    if (text.includes("\n") || largePaste(text)) {
+      put()
+      return
+    }
+    if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return
+    put()
+  }
+  const handleDrop = async (event: DragEvent) => {
+    if (input.isDialogActive()) return
+    event.preventDefault()
+    input.setDraggingType(null)
+    const plainText = event.dataTransfer?.getData("text/plain")
+    if (plainText?.startsWith("file:")) {
+      const path = plainText.slice("file:".length)
+      input.focusEditor()
+      input.addPart({ type: "file", path, content: `@${path}`, start: 0, end: 0 })
+      return
+    }
+    const files = event.dataTransfer?.files
+    if (files) await addAttachments(Array.from(files))
+  }
+
+  onMount(() => {
+    makeEventListener(document, "dragover", (event) => {
+      if (input.isDialogActive()) return
+      event.preventDefault()
+      if (event.dataTransfer?.types.includes("Files")) input.setDraggingType("image")
+      else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention")
+    })
+    makeEventListener(document, "dragleave", (event) => {
+      if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null)
+    })
+    makeEventListener(document, "drop", handleDrop)
+  })
+
+  return {
+    addAttachments,
+    handlePaste,
+    handleDrop,
+    pick(fallback: () => void) {
+      if (!input.picker) {
+        fallback()
+        return
+      }
+      void input
+        .picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file))
+        .catch(input.onError)
+    },
+  }
+}
+
+function dataUrl(file: File, mime: string) {
+  return new Promise<string>((resolve) => {
+    const reader = new FileReader()
+    reader.addEventListener("error", () => resolve(""))
+    reader.addEventListener("load", () => {
+      const value = typeof reader.result === "string" ? reader.result : ""
+      const index = value.indexOf(",")
+      resolve(index === -1 ? value : `data:${mime};base64,${value.slice(index + 1)}`)
+    })
+    reader.readAsDataURL(file)
+  })
+}
+
+const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
+const imageExtensions = new Map([
+  ["gif", "image/gif"],
+  ["jpeg", "image/jpeg"],
+  ["jpg", "image/jpeg"],
+  ["png", "image/png"],
+  ["webp", "image/webp"],
+])
+const textMimes = new Set([
+  "application/json",
+  "application/ld+json",
+  "application/toml",
+  "application/x-toml",
+  "application/x-yaml",
+  "application/xml",
+  "application/yaml",
+])
+
+async function attachmentMime(file: File) {
+  const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
+  if (imageMimes.has(type) || type === "application/pdf") return type
+  const index = file.name.lastIndexOf(".")
+  const suffix = index === -1 ? "" : file.name.slice(index + 1).toLowerCase()
+  const fallback = imageExtensions.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
+  if ((!type || type === "application/octet-stream") && fallback) return fallback
+  if (type.startsWith("text/") || textMimes.has(type) || type.endsWith("+json") || type.endsWith("+xml")) {
+    return "text/plain"
+  }
+  const bytes = new Uint8Array(await file.slice(0, 4096).arrayBuffer())
+  if (bytes.some((byte) => byte === 0)) return
+  const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length
+  if (bytes.length > 0 && control / bytes.length > 0.3) return
+  return "text/plain"
+}
+
+function cursorPosition(editor: HTMLElement) {
+  const selection = window.getSelection()
+  if (!selection || selection.rangeCount === 0) return 0
+  const range = selection.getRangeAt(0)
+  if (!editor.contains(range.startContainer)) return 0
+  const before = range.cloneRange()
+  before.selectNodeContents(editor)
+  before.setEnd(range.startContainer, range.startOffset)
+  return before.toString().replace(/\u200B/g, "").length
+}
+
+function largePaste(text: string) {
+  if (text.length >= 8000) return true
+  return text.split("\n").length - 1 >= 120
+}

+ 686 - 0
packages/session-ui/src/v2/components/prompt-input/index.tsx

@@ -0,0 +1,686 @@
+import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { AttachmentCardV2 } from "../attachment-card-v2"
+import { CommentCardV2 } from "../comment-card-v2"
+import { typeLabel } from "../../../components/message-file"
+import type {
+  PromptInputV2Attachment,
+  PromptInputV2Comment,
+  PromptInputV2Option,
+  PromptInputV2PersistedState,
+  PromptInputV2Prompt,
+  PromptInputV2Suggestion,
+} from "./types"
+import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction"
+
+export type {
+  PromptInputV2Attachment,
+  PromptInputV2Comment,
+  PromptInputV2Option,
+  PromptInputV2PersistedState,
+  PromptInputV2Suggestion,
+} from "./types"
+
+export type PromptInputV2Mode = "normal" | "shell"
+
+export type PromptInputV2Props = {
+  controller: PromptInputV2Interaction
+  disabled?: boolean
+  readOnly?: boolean
+  class?: string
+  modelControl?: JSX.Element
+}
+
+export function PromptInputV2(props: PromptInputV2Props) {
+  const state = props.controller.state
+  const view = props.controller.view
+  let editor: HTMLDivElement | undefined
+  let localInput = false
+  const mode = createMemo(() => state.mode)
+  const buttons = createMemo(() => ({
+    opacity: mode() === "normal" ? 1 : 0,
+    "pointer-events": mode() === "normal" ? ("auto" as const) : ("none" as const),
+    transition: "opacity 200ms ease",
+  }))
+
+  createEffect(() => {
+    const parts = props.controller.parts()
+    if (!editor) return
+    if (localInput) {
+      localInput = false
+      return
+    }
+    renderPromptInputV2Editor(editor, parts)
+  })
+
+  return (
+    <div class={`relative size-full flex flex-col gap-0 ${props.class ?? ""}`}>
+      <input
+        ref={props.controller.setFileInput}
+        type="file"
+        multiple
+        accept="image/png,image/jpeg,image/gif,image/webp,application/pdf,text/*,application/json,application/ld+json,application/toml,application/x-toml,application/x-yaml,application/xml,application/yaml,.c,.cc,.cjs,.conf,.cpp,.css,.csv,.cts,.env,.go,.gql,.graphql,.h,.hh,.hpp,.htm,.html,.ini,.java,.js,.json,.jsx,.log,.md,.mdx,.mjs,.mts,.py,.rb,.rs,.sass,.scss,.sh,.sql,.toml,.ts,.tsx,.txt,.xml,.yaml,.yml,.zsh"
+        class="hidden"
+        onChange={(event) => {
+          const list = event.currentTarget.files
+          if (list) props.controller.addAttachments(Array.from(list))
+          event.currentTarget.value = ""
+        }}
+      />
+      <Show when={state.popover.type !== "closed"}>
+        <PromptInputV2Popover
+          emptyLabel="No matching items"
+          items={props.controller.suggestions()}
+          activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
+          search={
+            state.popover.type === "command-menu"
+              ? {
+                  value: state.popover.query,
+                  label: "Commands",
+                  placeholder: "/",
+                  onValueChange: props.controller.setQuery,
+                  onKeyDown: props.controller.onKeyDown,
+                }
+              : undefined
+          }
+          onActiveChange={(item) => props.controller.dispatch({ type: "popover.active", id: item.id })}
+          onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })}
+        />
+      </Show>
+      <form
+        data-component="prompt-input-v2"
+        class="group/prompt-input relative min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
+        classList={{ "border border-v2-icon-icon-info border-dashed": state.drag === "active" }}
+        onSubmit={(event) => {
+          event.preventDefault()
+          if (!props.disabled) props.controller.submit()
+        }}
+        onDragEnter={props.controller.onDragEnter}
+        onDragOver={props.controller.onDragOver}
+        onDragLeave={props.controller.onDragLeave}
+        onDrop={props.controller.onDrop}
+      >
+        <Show when={state.drag === "active"}>
+          <div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
+            Drop files to attach
+          </div>
+        </Show>
+
+        <Show when={state.mode === "normal"}>
+          <PromptInputV2Attachments
+            attachments={props.controller.attachments()}
+            comments={props.controller.comments()}
+            activeCommentID={state.activeContextID}
+            removeLabel="Remove attachment"
+            onAttachmentClick={props.controller.openAttachment}
+            onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
+            onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
+            onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
+          />
+        </Show>
+
+        <div class="relative min-h-[60px]">
+          <div
+            ref={(element) => {
+              editor = element
+              props.controller.setEditor(element)
+              renderPromptInputV2Editor(element, props.controller.parts())
+            }}
+            data-component="prompt-input"
+            role="textbox"
+            aria-multiline="true"
+            aria-label="Prompt"
+            contenteditable={!props.disabled && !props.readOnly}
+            autocapitalize={state.mode === "normal" ? "sentences" : "off"}
+            autocorrect={state.mode === "normal" ? "on" : "off"}
+            spellcheck={state.mode === "normal"}
+            // @ts-expect-error
+            autocomplete="off"
+            class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
+            classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
+            onInput={(event) => {
+              const cursor = promptInputV2Cursor(event.currentTarget)
+              const prompt = parsePromptInputV2Editor(event.currentTarget)
+              const images = props.controller.parts().filter((part) => part.type === "image")
+              localInput = true
+              props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
+            }}
+            onKeyDown={(event) => {
+              if (props.controller.onKeyDown(event)) return
+              if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
+                event.preventDefault()
+                if (event.repeat) return
+                props.controller.submit()
+              }
+            }}
+            onKeyUp={(event) => props.controller.onCursor(promptInputV2Cursor(event.currentTarget))}
+            onPointerUp={(event) => props.controller.onCursor(promptInputV2Cursor(event.currentTarget))}
+            onPaste={props.controller.onPaste}
+            onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
+          />
+          <Show when={!props.controller.value()}>
+            <div
+              class="pointer-events-none absolute inset-x-0 top-0 px-4 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
+              classList={{ "font-mono!": state.mode === "shell" }}
+            >
+              {view.placeholder?.() ??
+                (state.mode === "shell" ? "Enter shell command..." : "Ask anything, / for commands, @ for context...")}
+            </div>
+          </Show>
+        </div>
+
+        <div class="flex h-11 items-center px-2">
+          <div
+            class="flex min-w-0 flex-1 items-center gap-1"
+            aria-hidden={state.mode === "shell"}
+            inert={state.mode === "shell" ? true : undefined}
+            style={buttons()}
+          >
+            <PromptInputV2AddMenu
+              disabled={state.mode === "shell"}
+              title="Add images and files"
+              keybind={["Mod", "U"]}
+              attachLabel="Images and files"
+              attachShortcut="Mod+U"
+              commandsLabel="Commands"
+              contextLabel="Context"
+              shellLabel="Shell command"
+              onAttach={props.controller.attach}
+              onCommands={props.controller.openCommands}
+              onContext={props.controller.openContext}
+              onShell={props.controller.openShell}
+            />
+            <Show when={view.agent}>
+              {(control) => (
+                <PromptInputV2ConfiguredSelect title="Choose agent" keybind={["Mod", "."]} control={control()} />
+              )}
+            </Show>
+            <Show
+              when={props.modelControl}
+              fallback={
+                <Show when={view.model}>
+                  {(control) => (
+                    <PromptInputV2ConfiguredSelect
+                      title="Choose model"
+                      keybind={["Mod", "M"]}
+                      control={control()}
+                      model
+                    />
+                  )}
+                </Show>
+              }
+            >
+              {props.modelControl}
+            </Show>
+            <Show when={view.variant}>
+              {(control) => (
+                <Show when={control().options().length > 1}>
+                  <PromptInputV2ConfiguredSelect title="Choose model variant" control={control()} />
+                </Show>
+              )}
+            </Show>
+          </div>
+          <PromptInputV2SubmitButton
+            mode={state.mode}
+            stopping={view.submit.stopping()}
+            disabled={!props.controller.canSubmit()}
+            sendLabel="Send"
+            stopLabel="Stop"
+            onSubmit={props.controller.submit}
+            onStop={props.controller.stop}
+          />
+        </div>
+      </form>
+    </div>
+  )
+}
+
+function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2Prompt) {
+  const active = document.activeElement === editor
+  editor.replaceChildren(
+    ...prompt.flatMap<Node>((part) => {
+      if (part.type === "image") return []
+      if (part.type === "text") return [document.createTextNode(part.content)]
+      const mention = document.createElement("span")
+      mention.textContent = part.content
+      mention.contentEditable = "false"
+      mention.dataset.mention =
+        part.type === "file" && part.mime === "application/x-directory" ? "reference" : part.type
+      if (part.type === "agent") mention.dataset.name = part.name
+      if (part.type === "file") {
+        mention.dataset.path = part.path
+        if (part.mime) mention.dataset.mime = part.mime
+        if (part.filename) mention.dataset.filename = part.filename
+      }
+      return [mention]
+    }),
+  )
+  if (!active) return
+  const selection = window.getSelection()
+  const range = document.createRange()
+  range.selectNodeContents(editor)
+  range.collapse(false)
+  selection?.removeAllRanges()
+  selection?.addRange(range)
+}
+
+function parsePromptInputV2Editor(editor: HTMLDivElement) {
+  const parts: Exclude<PromptInputV2Prompt[number], PromptInputV2Attachment>[] = []
+  let buffer = ""
+  let position = 0
+
+  const flush = () => {
+    if (!buffer) return
+    parts.push({ type: "text", content: buffer, start: position, end: position + buffer.length })
+    position += buffer.length
+    buffer = ""
+  }
+  const mention = (element: HTMLElement) => {
+    flush()
+    const content = element.textContent ?? ""
+    if (element.dataset.mention === "agent") {
+      parts.push({
+        type: "agent",
+        name: element.dataset.name ?? content.slice(1),
+        content,
+        start: position,
+        end: position + content.length,
+      })
+      position += content.length
+      return
+    }
+    parts.push({
+      type: "file",
+      path: element.dataset.path ?? content.slice(1),
+      content,
+      start: position,
+      end: position + content.length,
+      ...(element.dataset.mime ? { mime: element.dataset.mime } : {}),
+      ...(element.dataset.filename ? { filename: element.dataset.filename } : {}),
+    })
+    position += content.length
+  }
+  const visit = (node: Node) => {
+    if (node.nodeType === Node.TEXT_NODE) {
+      buffer += node.textContent ?? ""
+      return
+    }
+    if (!(node instanceof HTMLElement)) return
+    if (node.dataset.mention) {
+      mention(node)
+      return
+    }
+    if (node.tagName === "BR") {
+      buffer += "\n"
+      return
+    }
+    Array.from(node.childNodes).forEach(visit)
+  }
+
+  Array.from(editor.childNodes).forEach((node, index, nodes) => {
+    visit(node)
+    if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n"
+  })
+  flush()
+  if (
+    parts.every((part) => part.type === "text") &&
+    parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "")
+  ) {
+    return [{ type: "text" as const, content: "", start: 0, end: 0 }]
+  }
+  if (parts.length > 0) return parts
+  return [{ type: "text" as const, content: "", start: 0, end: 0 }]
+}
+
+function promptInputV2Cursor(editor: HTMLDivElement) {
+  const selection = window.getSelection()
+  if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
+  const range = selection.getRangeAt(0).cloneRange()
+  range.selectNodeContents(editor)
+  range.setEnd(selection.anchorNode!, selection.anchorOffset)
+  return range.toString().length
+}
+
+export function PromptInputV2Attachments(props: {
+  attachments: PromptInputV2Attachment[]
+  comments?: PromptInputV2Comment[]
+  activeCommentID?: string
+  removeLabel: string
+  onAttachmentClick?: (attachment: PromptInputV2Attachment) => void
+  onAttachmentRemove: (attachment: PromptInputV2Attachment) => void
+  onCommentClick?: (comment: PromptInputV2Comment) => void
+  onCommentRemove?: (comment: PromptInputV2Comment) => void
+}) {
+  return (
+    <Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
+      <div data-slot="prompt-attachments" class="relative">
+        <div
+          data-slot="prompt-attachments-scroll"
+          class="flex flex-nowrap gap-2 overflow-x-auto no-scrollbar px-2 pt-2 pb-1"
+        >
+          <For each={props.comments ?? []}>
+            {(comment) => (
+              <div class="relative group shrink-0">
+                <TooltipV2
+                  value={comment.comment}
+                  placement="top"
+                  openDelay={800}
+                  contentClass="max-w-[300px] break-words"
+                >
+                  <CommentCardV2
+                    comment={comment.comment ?? ""}
+                    path={comment.path}
+                    selection={comment.selection}
+                    active={comment.key === props.activeCommentID}
+                    onClick={() => props.onCommentClick?.(comment)}
+                  />
+                </TooltipV2>
+                <button
+                  type="button"
+                  onClick={() => props.onCommentRemove?.(comment)}
+                  class="absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
+                  aria-label={props.removeLabel}
+                >
+                  <IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
+                </button>
+              </div>
+            )}
+          </For>
+          <For each={props.attachments}>
+            {(attachment) => (
+              <div class="relative group shrink-0">
+                <TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
+                  <Show
+                    when={attachment.mime.startsWith("image/")}
+                    fallback={
+                      <AttachmentCardV2 title={attachment.filename}>
+                        {typeLabel(attachment.filename, attachment.mime)}
+                      </AttachmentCardV2>
+                    }
+                  >
+                    <img
+                      src={attachment.dataUrl}
+                      alt={attachment.filename}
+                      class="w-[58px] h-[46px] rounded-[6px] object-cover"
+                      onClick={() => props.onAttachmentClick?.(attachment)}
+                    />
+                    <div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
+                  </Show>
+                </TooltipV2>
+                <button
+                  type="button"
+                  onClick={() => props.onAttachmentRemove(attachment)}
+                  class="absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
+                  aria-label={props.removeLabel}
+                >
+                  <IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
+                </button>
+              </div>
+            )}
+          </For>
+        </div>
+        <div class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]" />
+        <div class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]" />
+      </div>
+    </Show>
+  )
+}
+
+export function PromptInputV2AddMenu(props: {
+  disabled?: boolean
+  title: string
+  keybind?: string[]
+  attachLabel: string
+  attachShortcut?: string
+  commandsLabel: string
+  contextLabel: string
+  shellLabel: string
+  onAttach: () => void
+  onCommands: () => void
+  onContext: () => void
+  onShell: () => void
+}) {
+  return (
+    <TooltipV2
+      placement="top"
+      value={
+        <>
+          {props.title}
+          <KeybindV2 keys={props.keybind ?? []} 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"
+          disabled={props.disabled}
+          aria-label={props.title}
+        />
+        <MenuV2.Portal>
+          <MenuV2.Content style={{ "min-width": "180px" }}>
+            <MenuV2.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
+              {props.attachLabel}
+            </MenuV2.Item>
+            <MenuV2.Separator />
+            <MenuV2.Item onSelect={props.onCommands} shortcut="/">
+              {props.commandsLabel}
+            </MenuV2.Item>
+            <MenuV2.Item onSelect={props.onContext} shortcut="@">
+              {props.contextLabel}
+            </MenuV2.Item>
+            <MenuV2.Item onSelect={props.onShell} shortcut="!">
+              {props.shellLabel}
+            </MenuV2.Item>
+          </MenuV2.Content>
+        </MenuV2.Portal>
+      </MenuV2>
+    </TooltipV2>
+  )
+}
+
+function PromptInputV2ConfiguredSelect(props: {
+  title: string
+  keybind?: string[]
+  control: PromptInputV2SelectControl
+  model?: boolean
+}) {
+  const current = () => props.control.current()
+  const providerID = () => props.control.options().find((option) => option.id === current())?.providerID
+  return (
+    <PromptInputV2Select
+      title={props.title}
+      keybind={props.keybind}
+      options={props.control.options()}
+      current={current()}
+      currentIcon={
+        <Show when={props.model && providerID()}>
+          <ProviderIcon id={providerID()!} class="size-4 shrink-0 opacity-60" />
+        </Show>
+      }
+      onSelect={props.control.onSelect}
+    />
+  )
+}
+
+export function PromptInputV2Select(props: {
+  title: string
+  keybind?: string[]
+  options: PromptInputV2Option[]
+  current: string
+  currentIcon?: JSX.Element
+  class?: string
+  onOpenChange?: (open: boolean) => void
+  onSelect: (id: string) => void
+}) {
+  return (
+    <MenuV2 gutter={6} modal={false} placement="top-start" onOpenChange={props.onOpenChange}>
+      <MenuV2.Trigger
+        as={ButtonV2}
+        variant="ghost-muted"
+        size="normal"
+        class={`max-w-[220px] justify-start ![font-weight:440] ${props.class ?? ""}`}
+        title={keybindTitle(props.title, props.keybind)}
+      >
+        {props.currentIcon}
+        <span class="truncate capitalize leading-5">
+          {props.options.find((option) => option.id === props.current)?.label ?? props.current}
+        </span>
+        <span class="-ml-0.5 -mr-1 flex shrink-0">
+          <IconV2 name="chevron-down" />
+        </span>
+      </MenuV2.Trigger>
+      <MenuV2.Portal>
+        <MenuV2.Content>
+          <MenuV2.RadioGroup value={props.current} onChange={props.onSelect}>
+            <For each={props.options}>
+              {(option) => (
+                <MenuV2.RadioItem value={option.id} class="capitalize" closeOnSelect>
+                  {option.label}
+                </MenuV2.RadioItem>
+              )}
+            </For>
+          </MenuV2.RadioGroup>
+        </MenuV2.Content>
+      </MenuV2.Portal>
+    </MenuV2>
+  )
+}
+
+export function PromptInputV2Popover(props: {
+  emptyLabel: string
+  items: PromptInputV2Suggestion[]
+  activeID?: string
+  search?: {
+    value: string
+    label: string
+    placeholder: string
+    onValueChange: (value: string) => void
+    onKeyDown: (event: KeyboardEvent) => void
+  }
+  onActiveChange: (item: PromptInputV2Suggestion) => void
+  onSelect: (item: PromptInputV2Suggestion) => void
+}) {
+  return (
+    <div
+      class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
+      onMouseDown={(event) => event.preventDefault()}
+    >
+      <Show when={props.search}>
+        {(search) => (
+          <div class="px-2 py-1">
+            <input
+              ref={(element) => requestAnimationFrame(() => element.focus())}
+              value={search().value}
+              aria-label={search().label}
+              placeholder={search().placeholder}
+              class="w-full bg-transparent text-[13px] leading-5 text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
+              onInput={(event) => search().onValueChange(event.currentTarget.value)}
+              onKeyDown={(event) => search().onKeyDown(event)}
+              onMouseDown={(event) => event.stopPropagation()}
+            />
+          </div>
+        )}
+      </Show>
+      <Show
+        when={props.items.length > 0}
+        fallback={<div class="px-2 py-1 text-v2-text-text-muted">{props.emptyLabel}</div>}
+      >
+        <For each={props.items}>
+          {(item) => (
+            <button
+              type="button"
+              data-suggestion-id={item.id}
+              class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-v2-overlay-simple-overlay-hover"
+              classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
+              onPointerMove={() => props.onActiveChange(item)}
+              onClick={() => props.onSelect(item)}
+            >
+              <div class="flex min-w-0 flex-1 items-center gap-2">
+                <PromptInputV2SuggestionIcon item={item} />
+                <span class="shrink-0 text-v2-text-text-base">{item.label}</span>
+                <Show when={item.description}>
+                  <span class="min-w-0 truncate text-v2-text-text-muted">{item.description}</span>
+                </Show>
+              </div>
+              <Show when={item.keybind?.length}>
+                <span class="shrink-0 text-v2-text-text-muted">{item.keybind?.join("+")}</span>
+              </Show>
+            </button>
+          )}
+        </For>
+      </Show>
+    </div>
+  )
+}
+
+export function PromptInputV2SubmitButton(props: {
+  mode: PromptInputV2Mode
+  stopping: boolean
+  disabled: boolean
+  sendLabel: string
+  stopLabel: string
+  onSubmit: () => void
+  onStop: () => void
+}) {
+  return (
+    <TooltipV2
+      placement="top"
+      inactive={!props.stopping && props.disabled}
+      value={props.stopping ? props.stopLabel : props.sendLabel}
+    >
+      <IconButton
+        data-action="prompt-submit"
+        type="button"
+        disabled={!props.stopping && props.disabled}
+        tabIndex={props.mode === "normal" ? undefined : -1}
+        icon={props.stopping ? "stop" : props.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={props.stopping ? props.stopLabel : props.sendLabel}
+        onClick={(event) => {
+          event.preventDefault()
+          event.stopPropagation()
+          if (props.stopping) {
+            props.onStop()
+            return
+          }
+          props.onSubmit()
+        }}
+      />
+    </TooltipV2>
+  )
+}
+
+function PromptInputV2SuggestionIcon(props: { item: PromptInputV2Suggestion }) {
+  if (props.item.kind === "agent") return <Icon name="brain" size="small" class="shrink-0 text-icon-info-active" />
+  if (props.item.kind === "command") return null
+  return (
+    <FileIcon
+      node={{ path: props.item.path ?? props.item.label, type: props.item.kind === "reference" ? "directory" : "file" }}
+      class="size-4 shrink-0"
+    />
+  )
+}
+
+function keybindTitle(label: string, keybind?: string[]) {
+  if (!keybind?.length) return label
+  return `${label} (${keybind.join("+")})`
+}

+ 463 - 0
packages/session-ui/src/v2/components/prompt-input/interaction.ts

@@ -0,0 +1,463 @@
+import { createEffect, on, type Accessor } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
+import { useFilteredList } from "@opencode-ai/ui/hooks"
+import { createPromptInputV2Attachments, type PromptInputV2AttachmentConfig } from "./attachments"
+import { createPromptInputV2Store, type PromptInputV2StoreInput } from "./store"
+import type {
+  PromptInputV2Attachment,
+  PromptInputV2Comment,
+  PromptInputV2History,
+  PromptInputV2HistoryEntry,
+  PromptInputV2Option,
+  PromptInputV2PersistedState,
+  PromptInputV2Suggestion,
+} from "./types"
+import {
+  createPromptInputV2InteractionState,
+  transitionPromptInputV2,
+  type PromptInputV2InteractionCommand,
+  type PromptInputV2InteractionEvent,
+} from "./machine"
+
+export type PromptInputV2SelectControl = {
+  options: Accessor<PromptInputV2Option[]>
+  current: Accessor<string>
+  onSelect: (id: string) => void
+}
+
+export type PromptInputV2ViewConfig = {
+  placeholder?: Accessor<string>
+  add?: {
+    onAttach: () => void
+  }
+  agent?: PromptInputV2SelectControl
+  model?: PromptInputV2SelectControl
+  variant?: PromptInputV2SelectControl
+  submit: {
+    stopping: Accessor<boolean>
+    working?: Accessor<boolean>
+    onSubmit: () => void
+    onStop: () => void
+  }
+  shell?: {
+    onOpen: () => void
+    onClose: () => void
+  }
+  onKeyDown?: (event: KeyboardEvent) => void
+  onPaste?: (event: ClipboardEvent) => void
+  onDrop?: (event: DragEvent) => void
+}
+
+export function createPromptInputV2State() {
+  return createStore(createPromptInputV2InteractionState())
+}
+
+export function createPromptInputV2Controller(input: {
+  store: PromptInputV2StoreInput
+  state?: ReturnType<typeof createPromptInputV2State>
+  identity?: Accessor<unknown>
+  history?: PromptInputV2History
+  commands: Accessor<PromptInputV2Suggestion[]>
+  context: Accessor<PromptInputV2Suggestion[]>
+  searchContextFiles: (query: string) => PromptInputV2Suggestion[] | Promise<PromptInputV2Suggestion[]>
+  openAttachment?: (attachment: PromptInputV2Attachment) => void
+  openContext?: (key: string) => void
+  onContextRemove?: (item: PromptInputV2Comment) => void
+  onEditor?: (element: HTMLElement) => void
+  onSuggestionSelect?: (item: PromptInputV2Suggestion) => (() => void) | void
+  view: PromptInputV2ViewConfig
+  attachments?: PromptInputV2AttachmentConfig
+}) {
+  let editor: HTMLElement | undefined
+  let fileInput: HTMLInputElement | undefined
+  const draft = createPromptInputV2Store(input.store)
+  const [state, setState] = input.state ?? createPromptInputV2State()
+  if (input.identity) {
+    createEffect(on(input.identity, () => setState(reconcile(createPromptInputV2InteractionState())), { defer: true }))
+  }
+  function addPart(part: PromptInputV2PersistedState["prompt"][number]) {
+    if (part.type === "image") return false
+    if (part.type === "file" || part.type === "agent") {
+      draft.addMention(part)
+      return true
+    }
+    const text = draft.state.prompt.map((item) => ("content" in item ? item.content : "")).join("")
+    const cursor = draft.state.cursor ?? text.length
+    draft.setText(text.slice(0, cursor) + part.content + text.slice(cursor))
+    return true
+  }
+  const attachments = input.attachments
+    ? createPromptInputV2Attachments({
+        ...input.attachments,
+        capture: () => ({
+          current: () => draft.state.prompt,
+          cursor: () => draft.state.cursor,
+          set: draft.setPrompt,
+        }),
+        editor: () => editor,
+        focusEditor: () => editor?.focus(),
+        addPart,
+        setDraggingType: (type) => dispatch({ type: type ? "drag.enter" : "drag.leave" }),
+      })
+    : undefined
+  const attach = () => {
+    if (!attachments) {
+      input.view.add?.onAttach()
+      return
+    }
+    attachments.pick(() => fileInput?.click())
+  }
+  const contextList = useFilteredList<PromptInputV2Suggestion>({
+    items: async (query) => {
+      const fixed = input.context().filter((item) => item.kind !== "file")
+      const recent = input.context().filter((item) => item.kind === "file" && item.recent)
+      if (!query.trim()) return [...fixed, ...recent]
+      const seen = new Set(recent.map((item) => item.id))
+      const files = (await input.searchContextFiles(query)).filter((item) => !seen.has(item.id))
+      return [...fixed, ...recent, ...files]
+    },
+    key: (item) => item.id,
+    filterKeys: ["label"],
+    skipFilter: (item) => item.kind === "file" && !item.recent,
+    groupBy: (item) => {
+      if (item.kind === "reference") return "reference"
+      if (item.kind === "agent") return "agent"
+      if (item.kind === "resource") return "resource"
+      if (item.recent) return "recent"
+      return "file"
+    },
+    sortGroupsBy: (a, b) => {
+      const order = ["reference", "agent", "resource", "recent", "file"]
+      return order.indexOf(a.category) - order.indexOf(b.category)
+    },
+  })
+  const commandList = useFilteredList<PromptInputV2Suggestion>({
+    items: () => input.commands(),
+    key: (item) => item.id,
+    filterKeys: ["trigger", "title"],
+  })
+  const list = () => (state.popover.type === "context" ? contextList : commandList)
+  const suggestions = () => list().flat()
+
+  const execute = (command: PromptInputV2InteractionCommand) => {
+    if (command.type === "draft.setText") {
+      draft.setText(command.value)
+      return
+    }
+    if (command.type === "mention.add") {
+      if (command.item.mention) draft.addMention(command.item.mention)
+      return
+    }
+    if (command.type === "popover.filter") {
+      ;(command.popover === "command" ? commandList : contextList).onInput(command.query)
+      return
+    }
+    if (command.type === "suggestion.select") {
+      const item = suggestions().find((entry) => entry.id === command.id)
+      if (item) dispatch({ type: "popover.select", item })
+      return
+    }
+    if (command.type === "focus.editor") requestAnimationFrame(() => editor?.focus())
+  }
+
+  function dispatch(event: PromptInputV2InteractionEvent) {
+    const mode = state.mode
+    const result = transitionPromptInputV2(state, event, draft.state)
+    setState(reconcile(result.state))
+    result.commands.forEach(execute)
+    if (mode !== result.state.mode) {
+      if (result.state.mode === "shell") input.view.shell?.onOpen()
+      if (result.state.mode === "normal") input.view.shell?.onClose()
+    }
+    if (event.type === "popover.select") {
+      const action = input.onSuggestionSelect?.(event.item)
+      if (!action) return result.handled
+      if (event.item.kind === "command") {
+        draft.setPrompt(
+          draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image"),
+          0,
+        )
+      }
+      action()
+    }
+    return result.handled
+  }
+
+  const onKeyDown = (event: KeyboardEvent) => {
+    if (
+      state.mode === "normal" &&
+      (event.metaKey || event.ctrlKey) &&
+      !event.altKey &&
+      !event.shiftKey &&
+      event.key.toLowerCase() === "u"
+    ) {
+      event.preventDefault()
+      attach()
+      return true
+    }
+    const handled = dispatch({
+      type: "key.down",
+      key: event.key,
+      ctrl: event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey,
+      composing: event.isComposing,
+      ids: suggestions().map((item) => item.id),
+      empty: draft.state.prompt.every((part) => !("content" in part) || part.content.length === 0),
+    })
+    if (handled) event.preventDefault()
+    if (handled && event.key !== "Enter" && event.key !== "Tab" && state.popover.type !== "closed") {
+      const activeID = state.popover.activeID ?? ""
+      requestAnimationFrame(() =>
+        document.querySelector(`[data-suggestion-id="${CSS.escape(activeID)}"]`)?.scrollIntoView({ block: "nearest" }),
+      )
+    }
+    if (handled) return true
+    const stop =
+      input.view.submit.working?.() &&
+      ((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") ||
+        event.key === "Escape")
+    if (stop) {
+      event.preventDefault()
+      input.view.submit.onStop()
+      return true
+    }
+    if (
+      !event.altKey &&
+      !event.ctrlKey &&
+      !event.metaKey &&
+      (event.key === "ArrowUp" || event.key === "ArrowDown") &&
+      navigateHistory(event.key === "ArrowUp" ? "up" : "down")
+    ) {
+      event.preventDefault()
+      return true
+    }
+    input.view.onKeyDown?.(event)
+    return event.defaultPrevented
+  }
+
+  createEffect(() => {
+    if (state.popover.type === "closed") return
+    const ids = suggestions().map((item) => item.id)
+    if (state.popover.activeID ? ids.includes(state.popover.activeID) : ids.length === 0) return
+    dispatch({ type: "popover.results", ids })
+  })
+
+  const restoreFocus = (cursor = draft.state.cursor ?? promptLength(draft.state.prompt)) => {
+    requestAnimationFrame(() => {
+      editor?.focus()
+      setEditorCursor(editor, cursor)
+    })
+  }
+
+  const applyHistory = (entry: PromptInputV2HistoryEntry, position: "start" | "end") => {
+    input.history?.restore?.(entry.metadata)
+    const cursor = position === "start" ? 0 : promptLength(entry.prompt)
+    draft.setPrompt(clonePrompt(entry.prompt), cursor)
+    restoreFocus(cursor)
+  }
+  const navigateHistory = (direction: "up" | "down") => {
+    if (!input.history || !editor) return false
+    const selection = window.getSelection()
+    if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false
+    const text = draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("")
+    if (!canNavigateHistory(direction, text, editorCursor(editor), state.historyIndex >= 0)) return false
+    const entries = input.history.entries(state.mode)
+    if (direction === "up") {
+      if (entries.length === 0 || state.historyIndex >= entries.length - 1) return false
+      if (state.historyIndex === -1) {
+        setState("savedHistory", {
+          prompt: clonePrompt(draft.state.prompt),
+          metadata: input.history.capture?.(),
+        })
+      }
+      const index = state.historyIndex + 1
+      setState("historyIndex", index)
+      applyHistory(entries[index]!, "start")
+      return true
+    }
+    if (state.historyIndex < 0) return false
+    if (state.historyIndex > 0) {
+      const index = state.historyIndex - 1
+      setState("historyIndex", index)
+      applyHistory(entries[index]!, "end")
+      return true
+    }
+    const saved = state.savedHistory ?? { prompt: [{ type: "text", content: "", start: 0, end: 0 }] }
+    setState({ historyIndex: -1, savedHistory: undefined })
+    applyHistory(saved, "end")
+    return true
+  }
+
+  return {
+    state,
+    view: input.view,
+    suggestions,
+    dispatch,
+    onKeyDown,
+    value() {
+      return draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("")
+    },
+    parts() {
+      return draft.state.prompt
+    },
+    addPart,
+    contextItem(id: string) {
+      return draft.state.context.items.find((item) => item.key === id)
+    },
+    comments() {
+      return draft.state.context.items.filter((item) => !!item.comment?.trim())
+    },
+    attachments(): PromptInputV2Attachment[] {
+      return draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image")
+    },
+    toggleContext(id: string) {
+      dispatch({ type: "context.active", id })
+      input.openContext?.(id)
+    },
+    removeContext(id: string) {
+      const item = draft.state.context.items.find((entry) => entry.key === id)
+      if (item) input.onContextRemove?.(item)
+      draft.removeContext(id)
+      if (state.activeContextID === id) dispatch({ type: "context.active", id })
+    },
+    openAttachment(attachment: PromptInputV2Attachment) {
+      input.openAttachment?.(attachment)
+    },
+    removeAttachment(id: string) {
+      draft.removeAttachment(id)
+    },
+    canSubmit() {
+      const persisted = draft.state
+      if (persisted.prompt.some((part) => part.type === "image")) return true
+      if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
+      return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
+    },
+    setEditor(element: HTMLElement) {
+      editor = element
+      input.onEditor?.(element)
+    },
+    restoreFocus,
+    onInput(value: string, prompt?: PromptInputV2PersistedState["prompt"], cursor?: number) {
+      if (prompt) draft.setPrompt(prompt, cursor)
+      dispatch({ type: "input.changed", value, persist: !prompt })
+    },
+    onCursor(cursor: number) {
+      draft.setCursor(cursor)
+    },
+    openCommands() {
+      dispatch({ type: "commands.open" })
+    },
+    openContext() {
+      dispatch({ type: "context.open" })
+    },
+    openShell() {
+      dispatch({ type: "mode.shell" })
+    },
+    closeShell() {
+      dispatch({ type: "mode.normal" })
+    },
+    submit() {
+      input.view.submit.onSubmit()
+      dispatch({ type: "popover.close" })
+    },
+    stop() {
+      input.view.submit.onStop()
+    },
+    addHistory(prompt: PromptInputV2PersistedState["prompt"], mode: "normal" | "shell") {
+      input.history?.add(prompt, mode)
+      setState({ historyIndex: -1, savedHistory: undefined })
+    },
+    resetHistory() {
+      setState({ historyIndex: -1, savedHistory: undefined })
+    },
+    onPaste(event: ClipboardEvent) {
+      const clipboard = event.clipboardData
+      if (
+        attachments &&
+        (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain"))
+      ) {
+        void attachments.handlePaste(event)
+        return
+      }
+      input.view.onPaste?.(event)
+    },
+    onDragEnter(event: DragEvent) {
+      event.preventDefault()
+      dispatch({ type: "drag.enter" })
+    },
+    onDragOver(event: DragEvent) {
+      event.preventDefault()
+    },
+    onDragLeave() {
+      dispatch({ type: "drag.leave" })
+    },
+    onDrop(event: DragEvent) {
+      event.preventDefault()
+      dispatch({ type: "drag.leave" })
+      if (attachments) {
+        event.stopPropagation()
+        void attachments.handleDrop(event)
+        return
+      }
+      input.view.onDrop?.(event)
+    },
+    attach,
+    setFileInput(element: HTMLInputElement) {
+      fileInput = element
+    },
+    addAttachments(files: File[]) {
+      if (attachments) void attachments.addAttachments(files)
+    },
+    setQuery(value: string) {
+      dispatch({ type: "popover.query", value })
+    },
+  }
+}
+
+export type PromptInputV2Interaction = ReturnType<typeof createPromptInputV2Controller>
+
+function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
+  const position = Math.max(0, Math.min(cursor, text.length))
+  if (inHistory) return position === 0 || position === text.length
+  if (direction === "up") return position === 0 && text.length === 0
+  return position === text.length
+}
+
+function clonePrompt(prompt: PromptInputV2PersistedState["prompt"]): PromptInputV2PersistedState["prompt"] {
+  return prompt.map((part) =>
+    part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
+  )
+}
+
+function promptLength(prompt: PromptInputV2PersistedState["prompt"]) {
+  return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
+}
+
+function editorCursor(editor: HTMLElement) {
+  const selection = window.getSelection()
+  if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
+  const range = selection.getRangeAt(0).cloneRange()
+  range.selectNodeContents(editor)
+  range.setEnd(selection.anchorNode!, selection.anchorOffset)
+  return range.toString().length
+}
+
+function setEditorCursor(editor: HTMLElement | undefined, cursor: number) {
+  if (!editor) return
+  const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT)
+  let remaining = cursor
+  let node = walker.nextNode()
+  while (node) {
+    const length = node.textContent?.length ?? 0
+    if (remaining <= length) {
+      const range = document.createRange()
+      range.setStart(node, remaining)
+      range.collapse(true)
+      const selection = window.getSelection()
+      selection?.removeAllRanges()
+      selection?.addRange(range)
+      return
+    }
+    remaining -= length
+    node = walker.nextNode()
+  }
+}

+ 133 - 0
packages/session-ui/src/v2/components/prompt-input/machine.test.ts

@@ -0,0 +1,133 @@
+import { describe, expect, test } from "bun:test"
+import type { PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types"
+import { createPromptInputV2InteractionState, transitionPromptInputV2 } from "./machine"
+
+const command: PromptInputV2Suggestion = {
+  id: "review",
+  kind: "command",
+  label: "/review",
+}
+
+function persisted(value = ""): PromptInputV2PersistedState {
+  return {
+    prompt: [{ type: "text", content: value, start: 0, end: value.length }],
+    cursor: value.length,
+    context: { items: [] },
+  }
+}
+
+describe("prompt input v2 interaction machine", () => {
+  test("opens inline commands only when slash is the entire prompt", () => {
+    const state = createPromptInputV2InteractionState()
+    const open = transitionPromptInputV2(state, { type: "input.changed", value: "/re" }, persisted())
+    const closed = transitionPromptInputV2(state, { type: "input.changed", value: "explain /re" }, persisted())
+
+    expect(open.state.popover).toEqual({ type: "command-inline", query: "re" })
+    expect(closed.state.popover).toEqual({ type: "closed" })
+  })
+
+  test("enters shell mode from an initial exclamation mark", () => {
+    const result = transitionPromptInputV2(
+      createPromptInputV2InteractionState(),
+      { type: "input.changed", value: "!", persist: false },
+      persisted("!"),
+    )
+
+    expect(result.state.mode).toBe("shell")
+    expect(result.commands).toContainEqual({ type: "draft.setText", value: "" })
+  })
+
+  test("leaves shell mode with escape", () => {
+    const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const }
+    const result = transitionPromptInputV2(
+      state,
+      { type: "key.down", key: "Escape", ctrl: false, composing: false, ids: [] },
+      persisted(),
+    )
+
+    expect(result.state.mode).toBe("normal")
+    expect(result.handled).toBeTrue()
+  })
+
+  test("leaves shell mode with backspace when empty", () => {
+    const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const }
+    const result = transitionPromptInputV2(
+      state,
+      { type: "key.down", key: "Backspace", ctrl: false, composing: false, ids: [], empty: true },
+      persisted(),
+    )
+
+    expect(result.state.mode).toBe("normal")
+    expect(result.handled).toBeTrue()
+  })
+
+  test("closes a popover with ctrl-g before stopping a run", () => {
+    const state = {
+      ...createPromptInputV2InteractionState(),
+      popover: { type: "context" as const, query: "", activeID: "first" },
+    }
+    const result = transitionPromptInputV2(
+      state,
+      { type: "key.down", key: "g", ctrl: true, composing: false, ids: ["first"] },
+      persisted(),
+    )
+
+    expect(result.state.popover).toEqual({ type: "closed" })
+    expect(result.handled).toBeTrue()
+  })
+
+  test("opens the searchable command menu for a populated draft", () => {
+    const result = transitionPromptInputV2(
+      createPromptInputV2InteractionState(),
+      { type: "commands.open" },
+      persisted("existing text"),
+    )
+
+    expect(result.state.popover).toEqual({ type: "command-menu", query: "" })
+    expect(result.state.focus).toBe("command-search")
+  })
+
+  test("prepends a menu command and preserves existing text as arguments", () => {
+    const open = transitionPromptInputV2(
+      createPromptInputV2InteractionState(),
+      { type: "commands.open" },
+      persisted("existing text"),
+    )
+    const selected = transitionPromptInputV2(open.state, { type: "popover.select", item: command }, persisted("existing text"))
+
+    expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review existing text" })
+    expect(selected.state.popover).toEqual({ type: "closed" })
+  })
+
+  test("stores selected context files as prompt file parts", () => {
+    const item: PromptInputV2Suggestion = {
+      id: "src/index.ts",
+      kind: "file",
+      label: "index.ts",
+      path: "src/index.ts",
+    }
+    const state = {
+      ...createPromptInputV2InteractionState(),
+      popover: { type: "context" as const, query: "index" },
+    }
+
+    const selected = transitionPromptInputV2(state, { type: "popover.select", item }, persisted("@index"))
+
+    expect(selected.commands).toContainEqual({ type: "mention.add", item })
+  })
+
+  test("loops active popover items with arrow keys", () => {
+    const state = {
+      ...createPromptInputV2InteractionState(),
+      popover: { type: "context" as const, query: "", activeID: "second" },
+    }
+    const result = transitionPromptInputV2(
+      state,
+      { type: "key.down", key: "ArrowDown", ctrl: false, composing: false, ids: ["first", "second"] },
+      persisted(),
+    )
+
+    expect(result.state.popover).toEqual({ type: "context", query: "", activeID: "first" })
+    expect(result.handled).toBeTrue()
+  })
+})

+ 266 - 0
packages/session-ui/src/v2/components/prompt-input/machine.ts

@@ -0,0 +1,266 @@
+import type { PromptInputV2HistoryEntry, PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types"
+
+export type PromptInputV2InteractionState = {
+  mode: "normal" | "shell"
+  popover:
+    | { type: "closed" }
+    | { type: "context"; query: string; activeID?: string }
+    | { type: "command-inline"; query: string; activeID?: string }
+    | { type: "command-menu"; query: string; activeID?: string }
+  drag: "idle" | "active"
+  focus: "editor" | "command-search" | "external"
+  activeContextID?: string
+  historyIndex: number
+  savedHistory?: PromptInputV2HistoryEntry
+}
+
+export type PromptInputV2InteractionEvent =
+  | { type: "input.changed"; value: string; persist?: boolean }
+  | { type: "commands.open" }
+  | { type: "context.open" }
+  | { type: "popover.query"; value: string }
+  | { type: "popover.results"; ids: string[] }
+  | { type: "popover.active"; id: string }
+  | { type: "popover.close" }
+  | { type: "popover.select"; item: PromptInputV2Suggestion }
+  | { type: "key.down"; key: string; ctrl: boolean; composing: boolean; ids: string[]; empty?: boolean }
+  | { type: "mode.shell" }
+  | { type: "mode.normal" }
+  | { type: "drag.enter" }
+  | { type: "drag.leave" }
+  | { type: "focus.editor" }
+  | { type: "focus.external" }
+  | { type: "context.active"; id: string }
+
+export type PromptInputV2InteractionCommand =
+  | { type: "draft.setText"; value: string }
+  | { type: "mention.add"; item: PromptInputV2Suggestion }
+  | { type: "popover.filter"; popover: "command" | "context"; query: string }
+  | { type: "suggestion.select"; id: string }
+  | { type: "focus.editor" }
+  | { type: "focus.command-search" }
+
+export type PromptInputV2Transition = {
+  state: PromptInputV2InteractionState
+  commands: PromptInputV2InteractionCommand[]
+  handled: boolean
+}
+
+export function createPromptInputV2InteractionState(): PromptInputV2InteractionState {
+  return {
+    mode: "normal",
+    popover: { type: "closed" },
+    drag: "idle",
+    focus: "external",
+    historyIndex: -1,
+  }
+}
+
+export function transitionPromptInputV2(
+  state: PromptInputV2InteractionState,
+  event: PromptInputV2InteractionEvent,
+  persisted: PromptInputV2PersistedState,
+): PromptInputV2Transition {
+  if (event.type === "input.changed") return inputChanged(state, event.value, event.persist !== false)
+  if (event.type === "commands.open") return openCommands(state, persisted)
+  if (event.type === "context.open") return openContext(state, persisted)
+  if (event.type === "popover.query") return queryChanged(state, event.value)
+  if (event.type === "popover.results") return resultsChanged(state, event.ids)
+  if (event.type === "popover.active") return activeChanged(state, event.id)
+  if (event.type === "popover.close") return changed({ ...state, popover: { type: "closed" } })
+  if (event.type === "popover.select") return suggestionSelected(state, event.item, persisted)
+  if (event.type === "key.down") return keyDown(state, event)
+  if (event.type === "mode.shell") return changed({ ...state, mode: "shell", popover: { type: "closed" } })
+  if (event.type === "mode.normal") return changed({ ...state, mode: "normal" })
+  if (event.type === "drag.enter") return changed({ ...state, drag: "active" })
+  if (event.type === "drag.leave") return changed({ ...state, drag: "idle" })
+  if (event.type === "focus.editor") return changed({ ...state, focus: "editor" })
+  if (event.type === "context.active") {
+    return changed({ ...state, activeContextID: state.activeContextID === event.id ? undefined : event.id })
+  }
+  return changed({ ...state, focus: "external" })
+}
+
+function inputChanged(state: PromptInputV2InteractionState, value: string, persist: boolean): PromptInputV2Transition {
+  const setText: PromptInputV2InteractionCommand[] = persist ? [{ type: "draft.setText", value }] : []
+  if (state.mode === "normal" && value === "!") {
+    return changed({ ...state, mode: "shell", popover: { type: "closed" }, focus: "editor" }, [
+      { type: "draft.setText", value: "" },
+    ])
+  }
+  const context = value.match(/(?:^|\s)@([^\s@]*)$/)
+  if (context) {
+    const query = context[1] ?? ""
+    return changed(
+      { ...state, popover: { type: "context", query }, focus: "editor" },
+      [
+        ...setText,
+        { type: "popover.filter", popover: "context", query },
+      ],
+    )
+  }
+
+  const command = value.match(/^\/([^\s/]*)$/)
+  if (command) {
+    const query = command[1] ?? ""
+    return changed(
+      { ...state, popover: { type: "command-inline", query }, focus: "editor" },
+      [
+        ...setText,
+        { type: "popover.filter", popover: "command", query },
+      ],
+    )
+  }
+
+  return changed(
+    { ...state, popover: state.popover.type === "command-menu" ? state.popover : { type: "closed" }, focus: "editor" },
+    setText,
+  )
+}
+
+function openCommands(
+  state: PromptInputV2InteractionState,
+  persisted: PromptInputV2PersistedState,
+): PromptInputV2Transition {
+  if (!populated(persisted)) {
+    return changed(
+      { ...state, popover: { type: "command-inline", query: "" }, focus: "editor" },
+      [
+        { type: "draft.setText", value: promptText(persisted) + "/" },
+        { type: "popover.filter", popover: "command", query: "" },
+        { type: "focus.editor" },
+      ],
+    )
+  }
+  return changed(
+    { ...state, popover: { type: "command-menu", query: "" }, focus: "command-search" },
+    [
+      { type: "popover.filter", popover: "command", query: "" },
+      { type: "focus.command-search" },
+    ],
+  )
+}
+
+function openContext(
+  state: PromptInputV2InteractionState,
+  persisted: PromptInputV2PersistedState,
+): PromptInputV2Transition {
+  return changed(
+    { ...state, popover: { type: "context", query: "" }, focus: "editor" },
+    [
+      { type: "draft.setText", value: promptText(persisted) + "@" },
+      { type: "popover.filter", popover: "context", query: "" },
+      { type: "focus.editor" },
+    ],
+  )
+}
+
+function queryChanged(state: PromptInputV2InteractionState, query: string): PromptInputV2Transition {
+  if (state.popover.type === "closed") return unchanged(state)
+  const popover = state.popover.type === "context" ? "context" : "command"
+  return changed(
+    { ...state, popover: { ...state.popover, query, activeID: undefined } },
+    [{ type: "popover.filter", popover, query }],
+  )
+}
+
+function resultsChanged(state: PromptInputV2InteractionState, ids: string[]): PromptInputV2Transition {
+  if (state.popover.type === "closed") return unchanged(state)
+  const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0]
+  if (activeID === state.popover.activeID) return unchanged(state)
+  return changed({ ...state, popover: { ...state.popover, activeID } })
+}
+
+function activeChanged(state: PromptInputV2InteractionState, id: string): PromptInputV2Transition {
+  if (state.popover.type === "closed" || state.popover.activeID === id) return unchanged(state)
+  return changed({ ...state, popover: { ...state.popover, activeID: id } })
+}
+
+function suggestionSelected(
+  state: PromptInputV2InteractionState,
+  item: PromptInputV2Suggestion,
+  persisted: PromptInputV2PersistedState,
+): PromptInputV2Transition {
+  const current = promptText(persisted)
+  const commands: PromptInputV2InteractionCommand[] = []
+  if (item.kind === "command") {
+    commands.push({
+      type: "draft.setText",
+      value:
+        state.popover.type === "command-menu"
+          ? current.trim()
+            ? `${item.label} ${current.trim()}`
+            : `${item.label} `
+          : replaceTrigger(current, "/", `${item.label} `),
+    })
+  } else {
+    commands.push({ type: "mention.add", item })
+  }
+  commands.push({ type: "focus.editor" })
+  return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, commands)
+}
+
+function keyDown(
+  state: PromptInputV2InteractionState,
+  event: Extract<PromptInputV2InteractionEvent, { type: "key.down" }>,
+): PromptInputV2Transition {
+  if (event.ctrl && event.key.toLowerCase() === "g") {
+    if (state.popover.type === "closed") return unchanged(state)
+    return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true)
+  }
+  if (state.popover.type === "closed") {
+    if (state.mode === "shell" && (event.key === "Escape" || (event.key === "Backspace" && event.empty))) {
+      return changed({ ...state, mode: "normal" }, [], true)
+    }
+    return unchanged(state)
+  }
+  if (event.key === "Escape") {
+    return changed(
+      { ...state, popover: { type: "closed" }, focus: "editor" },
+      [{ type: "focus.editor" }],
+      true,
+    )
+  }
+  if (event.key === "Tab" || (event.key === "Enter" && !event.composing)) {
+    if (!state.popover.activeID) return unchanged(state, true)
+    return unchanged(state, true, [{ type: "suggestion.select", id: state.popover.activeID }])
+  }
+  const direction = event.key === "ArrowDown" || (event.ctrl && event.key === "n") ? 1 : event.key === "ArrowUp" || (event.ctrl && event.key === "p") ? -1 : 0
+  if (!direction || event.ids.length === 0) return unchanged(state)
+  const current = state.popover.activeID ? event.ids.indexOf(state.popover.activeID) : -1
+  const index = current < 0 ? (direction === 1 ? 0 : event.ids.length - 1) : (current + direction + event.ids.length) % event.ids.length
+  return changed({ ...state, popover: { ...state.popover, activeID: event.ids[index] } }, [], true)
+}
+
+function promptText(persisted: PromptInputV2PersistedState) {
+  return persisted.prompt.map((part) => (part.type === "text" ? part.content : "")).join("")
+}
+
+function populated(persisted: PromptInputV2PersistedState) {
+  return (
+    !!promptText(persisted).trim() ||
+    persisted.context.items.length > 0 ||
+    persisted.prompt.some((part) => part.type === "file" || part.type === "image")
+  )
+}
+
+function replaceTrigger(value: string, trigger: "@" | "/", replacement: string) {
+  const index = value.lastIndexOf(trigger)
+  return index < 0 ? replacement : value.slice(0, index) + replacement
+}
+
+function changed(
+  state: PromptInputV2InteractionState,
+  commands: PromptInputV2InteractionCommand[] = [],
+  handled = false,
+): PromptInputV2Transition {
+  return { state, commands, handled }
+}
+
+function unchanged(
+  state: PromptInputV2InteractionState,
+  handled = false,
+  commands: PromptInputV2InteractionCommand[] = [],
+): PromptInputV2Transition {
+  return { state, commands, handled }
+}

+ 221 - 0
packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx

@@ -0,0 +1,221 @@
+// @ts-nocheck
+import { createStore } from "solid-js/store"
+import { PromptInputV2, type PromptInputV2PersistedState, type PromptInputV2Suggestion } from "."
+import { createPromptInputV2Controller } from "./interaction"
+import { createPromptInputV2Store } from "./store"
+import { createEffect } from "solid-js"
+
+const agents = [
+  { id: "build", label: "Build" },
+  { id: "plan", label: "Plan" },
+  { id: "review", label: "Review" },
+]
+
+const variants = [
+  { id: "default", label: "Default" },
+  { id: "fast", label: "Fast" },
+  { id: "thinking", label: "Thinking" },
+]
+
+const models = [
+  { id: "claude-sonnet", name: "Claude Sonnet", providerID: "anthropic" },
+  { id: "gpt-5", name: "GPT-5", providerID: "openai" },
+  { id: "gemini-pro", name: "Gemini Pro", providerID: "google" },
+]
+
+const contextSuggestions: PromptInputV2Suggestion[] = [
+  {
+    id: "file-prompt",
+    kind: "file",
+    label: "prompt-input-v2.tsx",
+    path: "src/components/prompt-input-v2.tsx",
+    recent: true,
+    mention: {
+      type: "file",
+      path: "src/components/prompt-input-v2.tsx",
+      content: "@src/components/prompt-input-v2.tsx",
+      start: 0,
+      end: 0,
+    },
+  },
+  {
+    id: "file-story",
+    kind: "file",
+    label: "prompt-input-v2.stories.tsx",
+    path: "src/components/prompt-input-v2.stories.tsx",
+    mention: {
+      type: "file",
+      path: "src/components/prompt-input-v2.stories.tsx",
+      content: "@src/components/prompt-input-v2.stories.tsx",
+      start: 0,
+      end: 0,
+    },
+  },
+  {
+    id: "agent-review",
+    kind: "agent",
+    label: "@review",
+    description: "Ask the review agent",
+    mention: { type: "agent", name: "review", content: "@review", start: 0, end: 0 },
+  },
+  {
+    id: "reference-docs",
+    kind: "reference",
+    label: "@UI guidelines",
+    path: "docs/ui.md",
+    description: "Project reference",
+    mention: {
+      type: "file",
+      path: "docs/ui.md",
+      content: "@UI guidelines",
+      start: 0,
+      end: 0,
+      mime: "application/x-directory",
+      filename: "UI guidelines",
+    },
+  },
+]
+
+const commandSuggestions: PromptInputV2Suggestion[] = [
+  {
+    id: "command-fix",
+    kind: "command",
+    label: "/fix",
+    trigger: "fix",
+    title: "Fix",
+    description: "Fix the current issue",
+    keybind: ["Enter"],
+  },
+  {
+    id: "command-review",
+    kind: "command",
+    label: "/review",
+    trigger: "review",
+    title: "Review",
+    description: "Review pending changes",
+  },
+  {
+    id: "command-test",
+    kind: "command",
+    label: "/test",
+    trigger: "test",
+    title: "Test",
+    description: "Run relevant tests",
+  },
+]
+
+function ControlledPromptInput() {
+  // Agent choice is a persisted user/workspace preference in v1, not part of PromptStore.
+  const [preferences, setPreferences] = createStore({ agent: "build" })
+
+  const [runtime, setRuntime] = createStore({
+    stopping: false,
+  })
+
+  // This matches the v1 PromptStore and can use the same persistence boundary.
+  const state = createStore<PromptInputV2PersistedState>({
+    prompt: [
+      { type: "text", content: "", start: 0, end: 0 },
+      {
+        type: "image",
+        id: "attachment-1",
+        filename: "requirements.md",
+        mime: "text/markdown",
+        dataUrl: "data:text/markdown;base64,IyBSZXF1aXJlbWVudHM=",
+      },
+    ],
+    cursor: 0,
+    model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null },
+    context: {
+      items: [
+        {
+          key: "file:src/components/prompt-input-v2.tsx:1:40",
+          type: "file",
+          path: "src/components/prompt-input-v2.tsx",
+          selection: { startLine: 1, startChar: 0, endLine: 40, endChar: 0 },
+          comment: "Keep this component context-free",
+        },
+      ],
+    },
+  })
+  const store = createPromptInputV2Store(state)
+
+  const controller = createPromptInputV2Controller({
+    store: state,
+    commands: () => commandSuggestions,
+    context: () => contextSuggestions,
+    searchContextFiles: (query) => {
+      const needle = query.trim().toLowerCase()
+      return contextSuggestions.filter(
+        (item) => item.kind === "file" && `${item.label} ${item.path ?? ""}`.toLowerCase().includes(needle),
+      )
+    },
+    view: {
+      add: {
+        onAttach: () => addAttachment("architecture.txt", "text/plain"),
+      },
+      agent: {
+        options: () => agents,
+        current: () => preferences.agent,
+        onSelect: (agent) => setPreferences("agent", agent),
+      },
+      model: {
+        options: () => models.map((model) => ({ id: model.id, label: model.name, providerID: model.providerID })),
+        current: () =>
+          models.find(
+            (model) => model.id === store.state.model?.modelID && model.providerID === store.state.model?.providerID,
+          )?.id ?? "",
+        onSelect(id) {
+          const model = models.find((item) => item.id === id)
+          if (!model) return
+          store.setModel({
+            providerID: model.providerID,
+            modelID: model.id,
+            variant: store.state.model?.variant,
+          })
+        },
+      },
+      variant: {
+        options: () => variants,
+        current: () => store.state.model?.variant ?? "default",
+        onSelect: (variant) => store.setVariant(variant === "default" ? null : variant),
+      },
+      submit: {
+        stopping: () => runtime.stopping,
+        working: () => runtime.stopping,
+        onSubmit: () => {
+          store.reset()
+          setRuntime("stopping", true)
+        },
+        onStop: () => setRuntime("stopping", false),
+      },
+      onDrop: (event) => addAttachment(event.dataTransfer?.files[0]?.name ?? "dropped-file.txt", "text/plain"),
+    },
+  })
+
+  const addAttachment = (filename: string, mime: string) => {
+    store.addAttachment({
+      type: "image",
+      id: `attachment-${store.state.prompt.filter((part) => part.type === "image").length + 1}`,
+      filename,
+      mime,
+      dataUrl: `data:${mime};base64,`,
+    })
+  }
+
+  return (
+    <div class="mx-auto flex max-w-[760px] flex-col gap-4 pt-32">
+      <PromptInputV2 controller={controller} />
+    </div>
+  )
+}
+
+export default {
+  title: "Session UI/PromptInputV2",
+  id: "session-ui-prompt-input-v2",
+  component: PromptInputV2,
+}
+
+export const ControlledComposition = {
+  render: () => <ControlledPromptInput />,
+}

+ 94 - 0
packages/session-ui/src/v2/components/prompt-input/store.test.ts

@@ -0,0 +1,94 @@
+import { describe, expect, test } from "bun:test"
+import { createStore } from "solid-js/store"
+import type { PromptInputV2PersistedState } from "./types"
+import { createPromptInputV2Store } from "./store"
+
+function createPromptStore() {
+  return createPromptInputV2Store(
+    createStore<PromptInputV2PersistedState>({
+      prompt: [
+        { type: "text", content: "old", start: 0, end: 3 },
+        {
+          type: "image",
+          id: "attachment-1",
+          filename: "notes.txt",
+          mime: "text/plain",
+          dataUrl: "data:text/plain;base64,",
+        },
+      ],
+      cursor: 3,
+      model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null },
+      context: { items: [] },
+    }),
+  )
+}
+
+describe("prompt input v2 store", () => {
+  test("accepts an accessor for the backing store", () => {
+    const [state, setState] = createStore<PromptInputV2PersistedState>({
+      prompt: [{ type: "text", content: "", start: 0, end: 0 }],
+      cursor: 0,
+      context: { items: [] },
+    })
+    const prompt = createPromptInputV2Store([() => state, setState])
+
+    prompt.setText("accessed")
+
+    expect(prompt.state.prompt).toEqual([{ type: "text", content: "accessed", start: 0, end: 8 }])
+    expect(prompt.state.cursor).toBe(8)
+  })
+
+  test("updates prompt text and cursor together while preserving attachments", () => {
+    const prompt = createPromptStore()
+
+    prompt.setText("updated")
+
+    expect(prompt.state.prompt).toEqual([
+      { type: "text", content: "updated", start: 0, end: 7 },
+      {
+        type: "image",
+        id: "attachment-1",
+        filename: "notes.txt",
+        mime: "text/plain",
+        dataUrl: "data:text/plain;base64,",
+      },
+    ])
+    expect(prompt.state.cursor).toBe(7)
+  })
+
+  test("mutates context, attachments, and model through shared actions", () => {
+    const prompt = createPromptStore()
+    const context = { key: "file:src/index.ts", type: "file" as const, path: "src/index.ts" }
+
+    prompt.addContext(context)
+    prompt.addContext(context)
+    prompt.addMention({ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 0 })
+    prompt.removeAttachment("attachment-1")
+    prompt.setVariant("thinking")
+
+    expect(prompt.state.context.items).toEqual([context])
+    expect(prompt.state.prompt).toEqual([
+      { type: "text", content: "old", start: 0, end: 3 },
+      { type: "file", path: "src/app.ts", content: "@src/app.ts", start: 3, end: 14 },
+      { type: "text", content: " ", start: 14, end: 15 },
+    ])
+    expect(prompt.state.model?.variant).toBe("thinking")
+
+    prompt.removeContext(context.key)
+    prompt.setPrompt([{ type: "text", content: "old", start: 0, end: 3 }], 3)
+    prompt.setModel(undefined)
+
+    expect(prompt.state.context.items).toEqual([])
+    expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
+    expect(prompt.state.model).toBeUndefined()
+  })
+
+  test("resets the prompt and cursor", () => {
+    const prompt = createPromptStore()
+
+    prompt.reset()
+
+    expect(prompt.state.prompt).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
+    expect(prompt.state.cursor).toBe(0)
+  })
+})

+ 114 - 0
packages/session-ui/src/v2/components/prompt-input/store.ts

@@ -0,0 +1,114 @@
+import { batch, type Accessor } from "solid-js"
+import type { SetStoreFunction, Store } from "solid-js/store"
+import type {
+  PromptInputV2AgentPart,
+  PromptInputV2Attachment,
+  PromptInputV2Comment,
+  PromptInputV2FilePart,
+  PromptInputV2Model,
+  PromptInputV2PersistedState,
+  PromptInputV2Prompt,
+} from "./types"
+
+export type PromptInputV2StoreTuple = [
+  Store<PromptInputV2PersistedState> | Accessor<Store<PromptInputV2PersistedState>>,
+  SetStoreFunction<PromptInputV2PersistedState>,
+]
+
+export type PromptInputV2StoreInput = PromptInputV2StoreTuple | Accessor<PromptInputV2StoreTuple>
+
+export function createPromptInputV2Store(input: PromptInputV2StoreInput) {
+  const tuple = () => (typeof input === "function" ? input() : input)
+  const store = () => {
+    const value = tuple()[0]
+    return typeof value === "function" ? value() : value
+  }
+  const setStore = () => tuple()[1]
+
+  return {
+    get state() {
+      return store()
+    },
+    setPrompt(prompt: PromptInputV2Prompt, cursor?: number) {
+      batch(() => {
+        setStore()("prompt", prompt)
+        if (cursor !== undefined) setStore()("cursor", cursor)
+      })
+    },
+    setCursor(cursor: number) {
+      setStore()("cursor", cursor)
+    },
+    setText(content: string) {
+      batch(() => {
+        setStore()("prompt", (prompt) => [
+          { type: "text", content, start: 0, end: content.length },
+          ...prompt.filter((part) => part.type !== "text"),
+        ])
+        setStore()("cursor", content.length)
+      })
+    },
+    reset() {
+      batch(() => {
+        setStore()("prompt", [{ type: "text", content: "", start: 0, end: 0 }])
+        setStore()("cursor", 0)
+      })
+    },
+    setModel(model: PromptInputV2Model | undefined) {
+      setStore()("model", model)
+    },
+    setVariant(variant: string | null) {
+      if (store().model) setStore()("model", "variant", variant)
+    },
+    addContext(item: PromptInputV2Comment) {
+      if (store().context.items.some((entry) => entry.key === item.key)) return
+      setStore()("context", "items", (items) => [...items, item])
+    },
+    removeContext(key: string) {
+      setStore()("context", "items", (items) => items.filter((item) => item.key !== key))
+    },
+    addMention(mention: PromptInputV2FilePart | PromptInputV2AgentPart) {
+      const text = store().prompt.map((part) => ("content" in part ? part.content : "")).join("")
+      const end = store().cursor ?? text.length
+      const start = text.slice(0, end).lastIndexOf("@")
+      setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention))
+      setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1)
+    },
+    addAttachment(attachment: PromptInputV2Attachment) {
+      setStore()("prompt", (prompt) => [...prompt, attachment])
+    },
+    removeAttachment(id: string) {
+      setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id))
+    },
+  }
+}
+
+export type PromptInputV2Store = ReturnType<typeof createPromptInputV2Store>
+
+function insertMention(
+  prompt: PromptInputV2Prompt,
+  start: number,
+  end: number,
+  mention: PromptInputV2FilePart | PromptInputV2AgentPart,
+): PromptInputV2Prompt {
+  let position = 0
+  const parts = prompt.flatMap<PromptInputV2Prompt[number]>((part) => {
+    if (part.type === "image") return [part]
+    const partStart = position
+    position += part.content.length
+    if (part.type !== "text" || start < partStart || end > position) return [part]
+    const before = part.content.slice(0, start - partStart)
+    const after = part.content.slice(end - partStart)
+    return [
+      ...(before ? [{ type: "text" as const, content: before, start: 0, end: 0 }] : []),
+      mention,
+      { type: "text" as const, content: ` ${after}`, start: 0, end: 0 },
+    ]
+  })
+  let offset = 0
+  return parts.map((part) => {
+    if (part.type === "image") return part
+    const next = { ...part, start: offset, end: offset + part.content.length }
+    offset = next.end
+    return next
+  })
+}

+ 106 - 0
packages/session-ui/src/v2/components/prompt-input/types.ts

@@ -0,0 +1,106 @@
+import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
+
+type PromptInputV2PartBase = {
+  content: string
+  start: number
+  end: number
+}
+
+export type PromptInputV2TextPart = PromptInputV2PartBase & {
+  type: "text"
+}
+
+export type PromptInputV2FilePart = PromptInputV2PartBase & {
+  type: "file"
+  path: string
+  selection?: PromptInputV2Selection
+  mime?: string
+  filename?: string
+  url?: string
+  source?: FilePartSource
+}
+
+export type PromptInputV2AgentPart = PromptInputV2PartBase & {
+  type: "agent"
+  name: string
+}
+
+export type PromptInputV2Attachment = {
+  type: "image"
+  id: string
+  filename: string
+  sourcePath?: string
+  mime: string
+  dataUrl: string
+}
+
+export type PromptInputV2Prompt = (
+  | PromptInputV2TextPart
+  | PromptInputV2FilePart
+  | PromptInputV2AgentPart
+  | PromptInputV2Attachment
+)[]
+
+export type PromptInputV2Model = {
+  providerID: string
+  modelID: string
+  variant?: string | null
+}
+
+export type PromptInputV2Selection = {
+  startLine: number
+  startChar: number
+  endLine: number
+  endChar: number
+}
+
+export type PromptInputV2Comment = {
+  type: "file"
+  key: string
+  path: string
+  selection?: PromptInputV2Selection
+  comment?: string
+  commentID?: string
+  commentOrigin?: "review" | "file"
+  preview?: string
+}
+
+export type PromptInputV2PersistedState = {
+  prompt: PromptInputV2Prompt
+  cursor?: number
+  model?: PromptInputV2Model
+  context: {
+    items: PromptInputV2Comment[]
+  }
+}
+
+export type PromptInputV2HistoryEntry = {
+  prompt: PromptInputV2Prompt
+  metadata?: unknown
+}
+
+export type PromptInputV2History = {
+  entries: (mode: "normal" | "shell") => PromptInputV2HistoryEntry[]
+  add: (prompt: PromptInputV2Prompt, mode: "normal" | "shell") => void
+  capture?: () => unknown
+  restore?: (metadata: unknown) => void
+}
+
+export type PromptInputV2Option = {
+  id: string
+  label: string
+  providerID?: string
+}
+
+export type PromptInputV2Suggestion = {
+  id: string
+  kind: "agent" | "command" | "file" | "reference" | "resource"
+  label: string
+  title?: string
+  trigger?: string
+  description?: string
+  path?: string
+  keybind?: string[]
+  recent?: boolean
+  mention?: PromptInputV2FilePart | PromptInputV2AgentPart
+}

+ 3 - 0
packages/storybook/.storybook/mocks/app/context/command.ts

@@ -22,5 +22,8 @@ export function useCommand() {
     keybind(id: string) {
       return keybinds[id]
     },
+    keybindParts(id: string) {
+      return keybinds[id]?.split("+") ?? []
+    },
   }
 }

+ 1 - 1
packages/storybook/.storybook/mocks/app/context/language.ts

@@ -10,7 +10,7 @@ const dict: Record<string, string> = {
   "prompt.loading": "Loading prompt...",
   "prompt.placeholder.normal": "Ask anything...",
   "prompt.placeholder.simple": "Ask anything...",
-  "prompt.placeholder.shell": "Run a shell command... {{example}}",
+  "prompt.placeholder.shell": "Enter shell command... {{example}}",
   "prompt.placeholder.summarizeComment": "Summarize this comment",
   "prompt.placeholder.summarizeComments": "Summarize these comments",
   "prompt.action.attachFile": "Attach files",

+ 2 - 0
packages/storybook/.storybook/mocks/app/context/sync.ts

@@ -12,6 +12,8 @@ const [data, setData] = createStore({
   session_working: () => false,
   agent: [{ name: "build", mode: "task", hidden: false }],
   command: [{ name: "fix", description: "Run fix command", source: "project" }],
+  reference: [],
+  mcp_resource: {},
 })
 
 const sync = {

+ 1 - 0
packages/storybook/package.json

@@ -21,6 +21,7 @@
     "@types/node": "catalog:",
     "@types/react": "18.0.25",
     "react": "18.2.0",
+    "react-dom": "18.2.0",
     "solid-js": "catalog:",
     "storybook": "^10.2.13",
     "storybook-solidjs-vite": "^10.0.9",