diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx
new file mode 100644
index 000000000..b56287a87
--- /dev/null
+++ b/packages/app/src/components/prompt-input.stories.tsx
@@ -0,0 +1,117 @@
+// @ts-nocheck
+import { createStore } from "solid-js/store"
+import { createPromptState } from "@/context/prompt"
+import { createPromptInputHistory, PromptInput } from "./prompt-input"
+
+function PromptInputExample() {
+ const state = createPromptState()
+ const history = createPromptInputHistory()
+ const [controls, setControls] = createStore({
+ agent: "build",
+ variant: undefined as string | undefined,
+ comments: 0,
+ tabs: [] as string[],
+ activeTab: undefined as string | undefined,
+ reviewOpen: false,
+ })
+ const model = {
+ current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
+ variant: {
+ list: () => ["fast", "thinking"],
+ current: () => controls.variant,
+ set: (variant?: string) => setControls("variant", variant),
+ },
+ }
+ const submission = {
+ abort() {},
+ handleSubmit(event: Event) {
+ event.preventDefault()
+ state.reset()
+ },
+ }
+ const inputControls = {
+ agents: {
+ available: [{ name: "review", hidden: false, mode: "subagent" }],
+ options: ["build", "review", "plan"],
+ get current() {
+ return controls.agent
+ },
+ loading: false,
+ visible: true,
+ select: (agent?: string) => setControls("agent", agent ?? "build"),
+ },
+ model: {
+ selection: model,
+ paid: true,
+ loading: false,
+ },
+ projects: {
+ available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
+ directory: "/tmp/story",
+ select() {},
+ add() {},
+ },
+ session: {
+ id: "story-session",
+ tabs: {
+ active: () => controls.activeTab,
+ all: () => controls.tabs,
+ open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])),
+ setActive: (tab: string) => setControls("activeTab", tab),
+ },
+ reviewPanel: {
+ opened: () => controls.reviewOpen,
+ open: () => setControls("reviewOpen", true),
+ },
+ },
+ newLayoutDesigns: true,
+ }
+ const addReviewComment = () => {
+ const comment = controls.comments + 1
+ setControls("comments", comment)
+ state.context.add({
+ type: "file",
+ path: "src/components/prompt-input.tsx",
+ selection: {
+ startLine: 84 + comment,
+ startChar: 0,
+ endLine: 84 + comment,
+ endChar: 0,
+ },
+ comment: `Review comment ${comment}`,
+ commentID: `review-comment-${comment}`,
+ commentOrigin: "review",
+ preview: "export const PromptInput = ...",
+ })
+ }
+
+ return (
+
+
+
+
+
+
+ )
+}
+
+export default {
+ title: "App/PromptInput",
+ id: "app-prompt-input",
+ component: PromptInput,
+}
+
+export const Basic = {
+ render: () => (
+
+ ),
+}
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index d9aaf336c..404893a23 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -17,8 +17,8 @@ import {
type JSX,
} from "solid-js"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
-import { createStore } from "solid-js/store"
-import { useLocal } from "@/context/local"
+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,
@@ -31,10 +31,7 @@ import {
FileAttachmentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
-import { useNavigate, useSearchParams } from "@solidjs/router"
import { useSDK } from "@/context/sdk"
-import { useServer } from "@/context/server"
-import { useTabs } from "@/context/tabs"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
@@ -46,14 +43,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
-import { useProviders } from "@/hooks/use-providers"
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"
-import { useSettings } from "@/context/settings"
-import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
@@ -73,18 +67,104 @@ 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 { useDirectoryPicker } from "./directory-picker"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
-import { useQueries } from "@tanstack/solid-query"
-import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
-import { base64Encode } from "@opencode-ai/core/util/encode"
import { displayName } from "@/pages/layout/helpers"
-interface PromptInputProps {
+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
+ }
+ projects: {
+ available: { name?: string; worktree: string; sandboxes?: string[] }[]
+ directory: string
+ select: (worktree: string) => void
+ add: (title: string) => void
+ }
+ 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
@@ -126,27 +206,18 @@ const EXAMPLES = [
export const PromptInput: Component = (props) => {
const sdk = useSDK()
- const navigate = useNavigate()
- const queryOptions = useQueryOptions()
const sync = useSync()
- const local = useLocal()
const files = useFile()
- const prompt = usePrompt()
+ const prompt = props.state ?? usePrompt()
const layout = useLayout()
- const server = useServer()
const comments = useComments()
const dialog = useDialog()
- const providers = useProviders()
const command = useCommand()
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
- const pickDirectory = useDirectoryPicker()
- const settings = useSettings()
- const tabsStore = useTabs()
- const [search] = useSearchParams<{ draftId?: string }>()
- const { params, tabs, view } = useSessionLayout()
+ const tabs = () => props.controls.session.tabs
let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement
@@ -204,7 +275,7 @@ export const PromptInput: Component = (props) => {
}).activeFileTab
const commentInReview = (path: string) => {
- const sessionID = params.id
+ const sessionID = props.controls.session.id
if (!sessionID) return false
const diffs = sync().data.session_diff[sessionID]
@@ -237,14 +308,14 @@ export const PromptInput: Component = (props) => {
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) {
- if (!view().reviewPanel.opened()) view().reviewPanel.open()
+ if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
layout.fileTree.setTab("changes")
tabs().setActive("review")
queueCommentFocus()
return
}
- if (!view().reviewPanel.opened()) view().reviewPanel.open()
+ if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
void tabs().open(tab)
@@ -269,8 +340,10 @@ export const PromptInput: Component = (props) => {
return paths
})
- const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
- const working = createMemo(() => sync().data.session_working(params.id ?? ""))
+ 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 imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
@@ -347,29 +420,14 @@ export const PromptInput: Component = (props) => {
})
const hasUserPrompt = createMemo(() => {
- const sessionID = params.id
+ const sessionID = props.controls.session.id
if (!sessionID) return false
const messages = sync().data.message[sessionID]
if (!messages) return false
return messages.some((m) => m.role === "user")
})
- const [history, setHistory] = persisted(
- Persist.global("prompt-history", ["prompt-history.v1"]),
- createStore<{
- entries: PromptHistoryStoredEntry[]
- }>({
- entries: [],
- }),
- )
- const [shellHistory, setShellHistory] = persisted(
- Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
- createStore<{
- entries: PromptHistoryStoredEntry[]
- }>({
- entries: [],
- }),
- )
+ const history = props.history ?? createPersistedPromptInputHistory()
const suggest = createMemo(() => !hasUserPrompt())
@@ -573,8 +631,8 @@ export const PromptInput: Component = (props) => {
}
createEffect(() => {
- params.id
- if (params.id) return
+ props.controls.session.id
+ if (props.controls.session.id) return
if (!suggest()) return
const interval = setInterval(() => {
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
@@ -603,11 +661,10 @@ export const PromptInput: Component = (props) => {
}
const agentList = createMemo(() =>
- sync()
- .data.agent.filter((agent) => !agent.hidden && agent.mode !== "primary")
+ props.controls.agents.available
+ .filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
)
- const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return
@@ -1033,11 +1090,7 @@ export const PromptInput: Component = (props) => {
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
- const currentHistory = mode === "shell" ? shellHistory : history
- const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
- const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
- if (next === currentHistory.entries) return
- setCurrentHistory("entries", next)
+ history.add(prompt, mode, mode === "shell" ? [] : historyComments())
}
createEffect(
@@ -1082,7 +1135,7 @@ export const PromptInput: Component = (props) => {
const navigateHistory = (direction: "up" | "down") => {
const result = navigatePromptHistory({
direction,
- entries: store.mode === "shell" ? shellHistory.entries : history.entries,
+ entries: history.entries(store.mode),
historyIndex: store.historyIndex,
currentPrompt: prompt.current(),
currentComments: historyComments(),
@@ -1096,6 +1149,7 @@ export const PromptInput: Component = (props) => {
}
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
+ prompt,
editor: () => editorRef,
isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type),
@@ -1122,38 +1176,41 @@ export const PromptInput: Component = (props) => {
/>
)
- const variants = createMemo(() => ["default", ...local.model.variant.list()])
+ const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
// Check provider variants directly: `variants` also includes the UI-only default option.
- const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
+ const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0)
const accepting = createMemo(() => {
- const id = params.id
+ const id = props.controls.session.id
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
return permission.isAutoAccepting(id, sdk().directory)
})
- const { abort, handleSubmit } = createPromptSubmit({
- info,
- imageAttachments,
- commentCount,
- autoAccept: () => accepting(),
- mode: () => store.mode,
- working,
- editor: () => editorRef,
- queueScroll,
- promptLength,
- addToHistory,
- resetHistoryNavigation: () => {
- resetHistoryNavigation(true)
- },
- setMode: (mode) => setStore("mode", mode),
- setPopover: (popover) => setStore("popover", popover),
- newSessionWorktree: () => props.newSessionWorktree,
- onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
- shouldQueue: props.shouldQueue,
- onQueue: props.onQueue,
- onAbort: props.onAbort,
- onSubmit: props.onSubmit,
- })
+ const { abort, handleSubmit } =
+ props.submission ??
+ createPromptSubmit({
+ prompt,
+ info,
+ imageAttachments,
+ commentCount,
+ autoAccept: () => accepting(),
+ mode: () => store.mode,
+ working,
+ editor: () => editorRef,
+ queueScroll,
+ promptLength,
+ addToHistory,
+ resetHistoryNavigation: () => {
+ resetHistoryNavigation(true)
+ },
+ setMode: (mode) => setStore("mode", mode),
+ setPopover: (popover) => setStore("popover", popover),
+ newSessionWorktree: () => props.newSessionWorktree,
+ onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
+ shouldQueue: props.shouldQueue,
+ onQueue: props.onQueue,
+ onAbort: props.onAbort,
+ onSubmit: props.onSubmit,
+ })
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
@@ -1317,17 +1374,9 @@ export const PromptInput: Component = (props) => {
}
}
- const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
- queries: [
- queryOptions().agents(pathKey(sdk().directory)),
- queryOptions().providers(null),
- queryOptions().providers(pathKey(sdk().directory)),
- ],
- }))
-
- const agentsLoading = () => agentsQuery.isLoading
+ const agentsLoading = () => props.controls.agents.loading
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
- const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
+ const providersLoading = () => props.controls.model.loading
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource(
@@ -1342,23 +1391,23 @@ export const PromptInput: Component = (props) => {
const modelControlState = createMemo(() => ({
loading: providersLoading(),
- paid: providers.paid().length > 0,
+ paid: props.controls.model.paid,
title: language.t("command.model.choose"),
keybind: command.keybind("model.choose"),
- model: local.model,
- providerID: local.model.current()?.provider?.id,
- modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"),
+ 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"),
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
- dialog.show(() => )
+ dialog.show(() => )
})
},
}))
const newSession = () => props.variant === "new-session"
- const projects = createMemo(() => layout.projects.list())
+ const projects = createMemo(() => props.controls.projects.available)
const projectForDirectory = (directory: string | undefined) => {
if (!directory) return
const key = pathKey(directory)
@@ -1366,13 +1415,13 @@ export const PromptInput: Component = (props) => {
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
)
}
- const selectedProject = createMemo(() => projectForDirectory(sdk().directory))
+ const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory))
const projectResults = createMemo(() => {
const search = picker.projectSearch.trim().toLowerCase()
if (!search) return projects()
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
})
- const showAgentControl = createMemo(() => settings.visibility.customAgents() && agentNames().length > 0)
+ const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
const selectProject = (worktree: string) => {
setPicker({
projectOpen: false,
@@ -1382,33 +1431,11 @@ export const PromptInput: Component = (props) => {
restoreFocus()
return
}
- layout.projects.open(worktree)
- server.projects.touch(worktree)
-
- // On the draft route, retarget the existing draft in place so we keep the same
- // draft id (and its tab/prompt) instead of spawning a new draft for the new directory.
- const draftID = search.draftId
- if (draftID) {
- tabsStore.updateDraft(draftID, { server: server.key, directory: worktree })
- restoreFocus()
- return
- }
-
- navigate(`/${base64Encode(worktree)}/session`)
+ props.controls.projects.select(worktree)
+ restoreFocus()
}
const addProject = () => {
- const conn = server.current
- if (!conn) return
- const select = (result: string | string[] | null) => {
- const directory = Array.isArray(result) ? result[0] : result
- if (!directory) return
- selectProject(directory)
- }
- pickDirectory({
- server: conn,
- title: language.t("command.project.open"),
- onSelect: select,
- })
+ props.controls.projects.add(language.t("command.project.open"))
}
const projectPickerState = createMemo(() => ({
@@ -1449,11 +1476,11 @@ export const PromptInput: Component = (props) => {
const agentControlState = createMemo(() => ({
title: language.t("command.agent.cycle"),
keybind: command.keybind("agent.cycle"),
- options: agentNames(),
- current: local.agent.current()?.name ?? "",
+ options: props.controls.agents.options,
+ current: props.controls.agents.current,
style: control(),
onSelect: (value) => {
- local.agent.set(value)
+ props.controls.agents.select(value)
restoreFocus()
},
}))
@@ -1485,7 +1512,7 @@ export const PromptInput: Component = (props) => {
t={(key) => language.t(key as Parameters[0])}
/>
-
+
= (props) => {
data-component="prompt-variant-control"
classList={{
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
- !local.model.variant.current() && !store.variantOpen,
+ !props.controls.model.selection.variant.current() && !store.variantOpen,
}}
>
= (props) => {