diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 984888c35d87..cde0c283cb15 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -6,6 +6,7 @@ import { useLocal } from "@/context/local" import { useFile } from "@/context/file" import { ContentPart, + ContextItem, DEFAULT_PROMPT, isPromptEqual, Prompt, @@ -26,6 +27,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" @@ -36,6 +38,7 @@ import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { @@ -88,6 +91,13 @@ const EXAMPLES = [ "prompt.example.25", ] as const +type SubmitMode = "queue" | "steer" +type QueuedFollowup = { + id: number + prompt: Prompt + context: (ContextItem & { key: string })[] +} + export const PromptInput: Component = (props) => { const sdk = useSDK() const sync = useSync() @@ -104,12 +114,14 @@ export const PromptInput: Component = (props) => { const permission = usePermission() const language = useLanguage() const platform = usePlatform() + const settings = useSettings() let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement const mirror = { input: false } + const ids = { followup: 0 } const scrollCursorIntoView = () => { const container = scrollRef @@ -214,6 +226,9 @@ export const PromptInput: Component = (props) => { draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean + followupMode: SubmitMode | undefined + queuedFollowups: QueuedFollowup[] + sendingQueued: boolean }>({ popover: null, historyIndex: -1, @@ -222,6 +237,9 @@ export const PromptInput: Component = (props) => { draggingType: null, mode: "normal", applyingHistory: false, + followupMode: undefined, + queuedFollowups: [], + sendingQueued: false, }) const placeholder = createMemo(() => promptPlaceholder({ @@ -249,6 +267,99 @@ export const PromptInput: Component = (props) => { entries: [], }), ) + const showFollowupMode = createMemo(() => store.mode === "normal" && store.queuedFollowups.length > 0) + const submitMode = createMemo(() => { + if (!working()) return "queue" + if (store.followupMode) return store.followupMode + return settings.general.followup() + }) + const clonePrompt = (value: Prompt): Prompt => + value.map((part) => { + if (part.type === "file") { + return { + ...part, + selection: part.selection + ? { + ...part.selection, + } + : undefined, + } + } + return { + ...part, + } + }) + const cloneContext = (items: (ContextItem & { key: string })[]) => + items.map((item) => ({ + ...item, + selection: item.selection + ? { + ...item.selection, + } + : undefined, + })) + const setContext = (items: (ContextItem & { key: string })[]) => { + prompt.context + .items() + .forEach((item) => { + prompt.context.remove(item.key) + }) + items.forEach((item) => { + const { key, ...value } = item + void key + prompt.context.add(value) + }) + } + const followupPreview = (queued: QueuedFollowup) => { + const text = queued.prompt + .map((part) => ("content" in part ? part.content : "")) + .join("") + .trim() + .replace(/\s+/g, " ") + if (text.length > 0) return text + if (queued.context.some((item) => !!item.comment?.trim())) return language.t("prompt.followup.commentOnly") + return "..." + } + const focusEditor = () => { + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, promptLength(prompt.current())) + queueScroll() + }) + } + const findFollowup = (id: number) => store.queuedFollowups.find((item) => item.id === id) + const editFollowup = (id: number) => { + const queued = findFollowup(id) + if (!queued) return + const parts = clonePrompt(queued.prompt) + setContext(queued.context) + prompt.set(parts, promptLength(parts)) + setStore("queuedFollowups", (items) => items.filter((item) => item.id !== id)) + setStore("followupMode", undefined) + focusEditor() + } + const clearFollowup = (id: number) => { + setStore("queuedFollowups", (items) => items.filter((item) => item.id !== id)) + setStore("followupMode", undefined) + } + const stageFollowup = () => { + const queued = { + id: ++ids.followup, + prompt: clonePrompt(prompt.current()), + context: cloneContext(prompt.context.items()), + } + setStore("queuedFollowups", (items) => [...items, queued]) + setContext([]) + prompt.reset() + setStore("followupMode", undefined) + focusEditor() + } + + createEffect(() => { + if (working()) return + if (!store.followupMode) return + setStore("followupMode", undefined) + }) const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) @@ -794,11 +905,12 @@ export const PromptInput: Component = (props) => { readClipboardImage: platform.readClipboardImage, }) - const { abort, handleSubmit } = createPromptSubmit({ + const { abort, handleSubmit: submitPrompt } = createPromptSubmit({ info, imageAttachments, commentCount, mode: () => store.mode, + submitMode, working, editor: () => editorRef, queueScroll, @@ -813,6 +925,40 @@ export const PromptInput: Component = (props) => { onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, onSubmit: props.onSubmit, }) + const sendQueuedFollowup = async (id: number, mode: SubmitMode) => { + const queued = findFollowup(id) + if (!queued) return + if (store.sendingQueued) return + setStore("sendingQueued", true) + setStore("queuedFollowups", (items) => items.filter((item) => item.id !== id)) + setStore("followupMode", mode) + const parts = clonePrompt(queued.prompt) + setContext(queued.context) + prompt.set(parts, promptLength(parts)) + const event = new Event("submit", { cancelable: true }) + try { + await submitPrompt(event) + } finally { + setStore("followupMode", undefined) + setStore("sendingQueued", false) + } + } + const handleSubmit = async (event: Event) => { + event.preventDefault() + if (store.mode === "normal" && working() && submitMode() === "queue" && (prompt.dirty() || commentCount() > 0)) { + stageFollowup() + return + } + await submitPrompt(event) + } + + createEffect(() => { + const queued = store.queuedFollowups[0] + if (!queued) return + if (working()) return + if (store.sendingQueued) return + void sendQueuedFollowup(queued.id, "queue") + }) const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Backspace") { @@ -858,7 +1004,7 @@ export const PromptInput: Component = (props) => { // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input // and should always insert a newline regardless of composition state - if (event.key === "Enter" && event.shiftKey) { + if (event.key === "Enter" && event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) { addPart({ type: "text", content: "\n", start: 0, end: 0 }) event.preventDefault() return @@ -923,7 +1069,15 @@ export const PromptInput: Component = (props) => { return } - // Note: Shift+Enter is handled earlier, before IME check + const invertFollowup = event.key === "Enter" && event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey) + if (invertFollowup) { + const mode = settings.general.followup() === "queue" ? "steer" : "queue" + setStore("followupMode", mode) + void handleSubmit(event).finally(() => setStore("followupMode", undefined)) + return + } + + // Note: plain Shift+Enter is handled earlier, before IME check if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } @@ -953,6 +1107,78 @@ export const PromptInput: Component = (props) => { commandKeybind={command.keybind} t={(key) => language.t(key as Parameters[0])} /> + +
+ + {(queued, index) => ( +
0, + }} + > +
+ + {followupPreview(queued)} +
+ + clearFollowup(queued.id)} + disabled={store.sendingQueued} + aria-label={language.t("common.delete")} + /> + + + + + editFollowup(queued.id)}> +
+ +
+ {language.t("prompt.followup.menu.editMessage")} +
+ + settings.general.setFollowup(settings.general.followup() === "queue" ? "steer" : "queue") + } + > +
+ +
+ + {settings.general.followup() === "queue" + ? language.t("prompt.followup.menu.turnOffQueueing") + : language.t("prompt.followup.menu.turnOnQueueing")} + +
+
+
+
+
+ )} +
+
+
({ sentShell.push(directory) return { data: undefined } }, - prompt: async () => ({ data: undefined }), + prompt: async () => { + sentPrompt.push(directory) + return { data: undefined } + }, + promptAsync: async () => { + sentPromptAsync.push(directory) + return { data: undefined } + }, command: async () => ({ data: undefined }), abort: async () => ({ data: undefined }), }, @@ -137,6 +146,8 @@ beforeEach(() => { createdClients.length = 0 createdSessions.length = 0 sentShell.length = 0 + sentPrompt.length = 0 + sentPromptAsync.length = 0 syncedDirectories.length = 0 selected = "/repo/worktree-a" }) @@ -148,6 +159,7 @@ describe("prompt submit worktree selection", () => { imageAttachments: () => [], commentCount: () => 0, mode: () => "shell", + submitMode: () => "queue", working: () => false, editor: () => undefined, queueScroll: () => undefined, @@ -172,4 +184,58 @@ describe("prompt submit worktree selection", () => { expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) }) + + test("uses queue mode for normal prompts", async () => { + const submit = createPromptSubmit({ + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + mode: () => "normal", + submitMode: () => "queue", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + await submit.handleSubmit(event) + await Promise.resolve() + await Promise.resolve() + + expect(sentPromptAsync).toEqual(["/repo/main"]) + expect(sentPrompt).toEqual([]) + }) + + test("uses steer mode for normal prompts", async () => { + const submit = createPromptSubmit({ + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + mode: () => "normal", + submitMode: () => "steer", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + await submit.handleSubmit(event) + await Promise.resolve() + await Promise.resolve() + + expect(sentPrompt).toEqual(["/repo/main"]) + expect(sentPromptAsync).toEqual([]) + }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 9a1fba5d5c49..d48142eea0e9 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -29,6 +29,7 @@ type PromptSubmitInput = { imageAttachments: Accessor commentCount: Accessor mode: Accessor<"normal" | "shell"> + submitMode: Accessor<"queue" | "steer"> working: Accessor editor: () => HTMLDivElement | undefined queueScroll: () => void @@ -116,6 +117,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") const images = input.imageAttachments().slice() const mode = input.mode() + const submitMode = input.submitMode() if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) { if (input.working()) abort() @@ -385,14 +387,19 @@ export function createPromptSubmit(input: PromptSubmitInput) { const send = async () => { const ok = await waitForWorktree() if (!ok) return - await client.session.promptAsync({ + const args = { sessionID: session.id, agent, model, messageID, parts: requestParts, variant, - }) + } + if (submitMode === "steer") { + await client.session.prompt(args) + return + } + await client.session.promptAsync(args) } void send().catch((err) => { diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index d5a0b813b6c2..7f7607d29b1d 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -114,6 +114,12 @@ export const SettingsGeneral: Component = () => { label: language.label(locale), })), ) + const followupOptions = createMemo( + (): { value: "queue" | "steer"; label: string }[] => [ + { value: "queue", label: language.t("settings.general.followup.queue") }, + { value: "steer", label: language.t("settings.general.followup.steer") }, + ], + ) const fontOptions = [ { value: "ibm-plex-mono", label: "font.option.ibmPlexMono" }, @@ -133,7 +139,6 @@ export const SettingsGeneral: Component = () => { const fontOptionsList = [...fontOptions] const soundOptions = [...SOUND_OPTIONS] - const soundSelectProps = (current: () => string, set: (id: string) => void) => ({ options: soundOptions, current: soundOptions.find((o) => o.id === current()), @@ -416,6 +421,31 @@ export const SettingsGeneral: Component = () => { ) + const BehaviorSection = () => ( +
+

{language.t("settings.general.section.behavior")}

+ +
+ +