diff --git a/bun.lock b/bun.lock
index 3d8722b1522..5a28b7cf74d 100644
--- a/bun.lock
+++ b/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",
diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts
index 4219699f28c..9ddb4847094 100644
--- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts
+++ b/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)
diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts
index a73cc0ccdd7..bdf3f55bdc1 100644
--- a/packages/app/e2e/smoke/session-timeline.spec.ts
+++ b/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" }))
}
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 5592e2f4ead..b4496ba7de1 100644
--- a/packages/app/src/app.tsx
+++ b/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"
diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx
new file mode 100644
index 00000000000..4797218fdf8
--- /dev/null
+++ b/packages/app/src/components/prompt-input-v2.tsx
@@ -0,0 +1,588 @@
+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,
+ "variant" | "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 (
+
+
+ dialog.show(() => )
+ }
+ />
+ }
+ />
+
+ )
+}
+
+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((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[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(() => [
+ ...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(() =>
+ 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(() => ),
+ 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((previous) => previous ?? props.loading)
+ const content = () => (
+ <>
+
+ {(providerID) => (
+
+ )}
+
+ {props.modelName}
+
+
+
+ >
+ )
+ return (
+
+
+ {props.title}
+
+ >
+ }
+ >
+
+ {content()}
+
+ }
+ >
+
+ {content()}
+
+
+
+
+ )
+}
+
+function openComment(
+ item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
+ props: PromptInputV2ControllerProps,
+ sync: ReturnType,
+ layout: ReturnType,
+ files: ReturnType,
+ comments: ReturnType,
+) {
+ 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())
+}
diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx
index 362a7414018..5e0abbe1374 100644
--- a/packages/app/src/components/prompt-input.stories.tsx
+++ b/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,
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index a820067a4e7..f2eb2ad100d 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -13,7 +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 {
@@ -49,7 +48,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 +58,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
-
-export type PromptInputHistory = {
- entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
- add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
-}
-
-export type PromptInputSubmission = {
- abort: () => Promise | void
- handleSubmit: (event: Event) => Promise | 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["model"]
- paid: boolean
- loading: boolean
- }
- session: {
- id?: string
- tabs: {
- active: () => string | undefined
- all: () => string[]
- open: (tab: string) => void | Promise
- setActive: (tab: string) => void
- }
- reviewPanel: {
- opened: () => boolean
- open: () => void
- }
- }
- newLayoutDesigns: boolean
-}
-
-export function createPromptInputHistory(): PromptInputHistory {
- const [normal, setNormal] = createStore({ entries: [] })
- const [shell, setShell] = createStore({ entries: [] })
- return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
-}
-
-type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
-
-function createPromptInputHistoryStore(
- normal: Store,
- setNormal: SetStoreFunction,
- shell: Store,
- setShell: SetStoreFunction,
-): 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({ entries: [] }),
- )
- const [shell, setShell] = persisted(
- Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
- createStore({ 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",
@@ -1539,10 +1450,7 @@ export const PromptInput: Component = (props) => {
(p) => p,
)
- const designPlaceholder = () => {
- if (store.mode === "shell") return placeholder()
- return "Ask anything, / for commands, @ for context..."
- }
+ const designPlaceholder = () => promptDesignPlaceholder(store.mode, placeholder())
const modelControlState = createMemo(() => ({
loading: providersLoading(),
@@ -1564,7 +1472,6 @@ export const PromptInput: Component = (props) => {
dialog.show(() => )
},
}))
-
const newSession = () => props.variant === "new-session"
const bindEditorRef = (el: HTMLDivElement) => {
editorRef = el
@@ -1755,7 +1662,6 @@ export const PromptInput: Component = (props) => {
- {props.toolbar}
string
}
-type PromptAttachmentsInput = {
+export type PromptAttachmentsInput = {
prompt: ReturnType
editor: () => HTMLDivElement | undefined
isDialogActive: () => boolean
diff --git a/packages/app/src/components/prompt-input/contracts.ts b/packages/app/src/components/prompt-input/contracts.ts
new file mode 100644
index 00000000000..2f8dc0d5aa2
--- /dev/null
+++ b/packages/app/src/components/prompt-input/contracts.ts
@@ -0,0 +1,59 @@
+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
+
+export type PromptInputSubmission = {
+ abort: () => Promise | void
+ handleSubmit: (event: Event) => Promise | 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["model"]
+ paid: boolean
+ loading: boolean
+ }
+ session: {
+ id?: string
+ tabs: {
+ active: () => string | undefined
+ all: () => string[]
+ open: (tab: string) => void | Promise
+ setActive: (tab: string) => void
+ }
+ reviewPanel: {
+ opened: () => boolean
+ open: () => void
+ }
+ }
+ newLayoutDesigns: boolean
+}
+
+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
+}
diff --git a/packages/app/src/components/prompt-input/history-store.ts b/packages/app/src/components/prompt-input/history-store.ts
new file mode 100644
index 00000000000..2ee91fa8638
--- /dev/null
+++ b/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,
+ setNormal: SetStoreFunction,
+ shell: Store,
+ setShell: SetStoreFunction,
+): 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({ entries: [] })
+ const [shell, setShell] = createStore({ entries: [] })
+ return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
+}
+
+export function createPersistedPromptInputHistory() {
+ const [normal, setNormal] = persisted(
+ Persist.global("prompt-history", ["prompt-history.v1"]),
+ createStore({ entries: [] }),
+ )
+ const [shell, setShell] = persisted(
+ Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
+ createStore({ entries: [] }),
+ )
+ return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
+}
diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts
index 6669f136147..cbb4c45b7e3 100644
--- a/packages/app/src/components/prompt-input/placeholder.ts
+++ b/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..."
+}
diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts
index bce06b4347b..f563a509822 100644
--- a/packages/app/src/components/prompt-input/submit.test.ts
+++ b/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 | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
+const [promptStore, setPromptStore] = createStore({
+ 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,
diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx
index 45d0371fda5..1e5445517db 100644
--- a/packages/app/src/components/prompt-project-selector.tsx
+++ b/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,
diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts
index 462cfa638b8..73196289e2f 100644
--- a/packages/app/src/context/prompt-state.ts
+++ b/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) {
const actions = createPromptActions(setStore)
const value = {
+ store: [() => store, setStore] as [Accessor, SetStoreFunction],
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index 14147b6616f..41a99f3b67e 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/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 = (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),
diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx
index c2668e1567b..fe064af047d 100644
--- a/packages/app/src/pages/new-session.tsx
+++ b/packages/app/src/pages/new-session.tsx
@@ -1,13 +1,13 @@
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
-import { useSearchParams } from "@solidjs/router"
+import { useLocation, useSearchParams } from "@solidjs/router"
import { Tooltip } from "@opencode-ai/ui/tooltip"
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,
@@ -64,8 +64,6 @@ export default function NewSessionPage() {
useComposerCommands({ model })
- let inputRef: HTMLDivElement | undefined
-
const inputController = createPromptInputController({
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
@@ -73,29 +71,6 @@ export default function NewSessionPage() {
model,
})
const projectControls = createPromptProjectControls()
- const projectController = createPromptProjectController({
- controls: projectControls,
- onDone: () => inputRef?.focus(),
- })
-
- command.register("new-session", () => [
- {
- id: "command.palette",
- title: language.t("command.palette"),
- hidden: true,
- onSelect: async () => {
- const { DialogSelectFile } = await import("@/components/dialog-select-file")
- void dialog.show(() => )
- },
- },
- {
- id: "input.focus",
- title: language.t("command.input.focus"),
- category: language.t("command.category.view"),
- keybind: "ctrl+l",
- onSelect: () => inputRef?.focus(),
- },
- ])
const [store, setStore] = createStore<{ worktree?: string }>({})
const rightMount = useTitlebarRightMount()
@@ -115,6 +90,39 @@ export default function NewSessionPage() {
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: promptInputV2Controller.restoreFocus,
+ })
+
+ command.register("new-session", () => [
+ {
+ id: "command.palette",
+ title: language.t("command.palette"),
+ hidden: true,
+ onSelect: async () => {
+ const { DialogSelectFile } = await import("@/components/dialog-select-file")
+ void dialog.show(() => )
+ },
+ },
+ {
+ id: "input.focus",
+ title: language.t("command.input.focus"),
+ category: language.t("command.category.view"),
+ keybind: "ctrl+l",
+ onSelect: () => promptInputV2Controller.restoreFocus(),
+ },
+ ])
createEffect(() => {
if (!prompt.ready()) return
@@ -128,16 +136,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 (
+ {suspendUntilPromptReady()}
{(mount) => (
@@ -154,63 +164,44 @@ export default function NewSessionPage() {
-
- {language.t("prompt.loading")}
-
- }
- >
-
-
{
- inputRef = el
+
+
+
+
+
+
+ setStore("worktree", undefined)}
- onSubmit={() => comments.clear()}
- toolbar={
-
-
-
- }
- />
-
-
-
+
+
+
+ setStore(
+ "worktree",
+ value === "main" && sync().project?.worktree !== sdk().directory
+ ? sync().project?.worktree
+ : value,
+ )
+ }
+ onDone={promptInputV2Controller.restoreFocus}
/>
-
-
- setStore(
- "worktree",
- value === "main" && sync().project?.worktree !== sdk().directory
- ? sync().project?.worktree
- : value,
- )
- }
- onDone={() => inputRef?.focus()}
- />
-
-
-
-
-
+
+
+
+
+ {/**/}
{
- inputRef = 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 (
+
+ )
}}
- 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)
- }}
- />
+
}
/>
)
diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts
index 3ead9599bb9..cbced5f8c01 100644
--- a/packages/app/src/pages/session/composer/session-composer-controls.ts
+++ b/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"
diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json
index 317b3359974..063836e2564 100644
--- a/packages/session-ui/package.json
+++ b/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",
diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.ts b/packages/session-ui/src/v2/components/prompt-input/attachments.ts
new file mode 100644
index 00000000000..24c07ca445d
--- /dev/null
+++ b/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,
+ ) => Promise
+ directory: () => string
+ isDialogActive: () => boolean
+ warn: () => void
+ onError: (error: unknown) => void
+ readClipboardImage?: () => Promise
+ 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((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
+}
diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx
new file mode 100644
index 00000000000..fb0e8037444
--- /dev/null
+++ b/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 (
+
+
{
+ const list = event.currentTarget.files
+ if (list) props.controller.addAttachments(Array.from(list))
+ event.currentTarget.value = ""
+ }}
+ />
+
+ props.controller.dispatch({ type: "popover.active", id: item.id })}
+ onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })}
+ />
+
+