Skip to content
43 changes: 39 additions & 4 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -540,22 +540,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})

const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
// The prompt state stays empty while a composition is in flight (input events
// are ignored until compositionend), so the placeholder has to watch the
// composing text in the editor itself or it renders under it.
const [composingText, setComposingText] = createSignal(false)
const syncComposingText = () => setComposingText(!!editorRef?.textContent?.replace(/[\n\u200B]/g, ""))

let lastCompositionEnd = 0
// Safari fires compositionend BEFORE the confirming Enter keydown, so that
// keydown reports isComposing=false (keyCode 229 on Safari 26, 13 in older
// reports). Treat any key event within 100ms of compositionend as part of
// the IME confirmation. Compare with performance.now() rather than
// event.timeStamp so the guard never depends on the event clock source.
const isImeComposing = (event: KeyboardEvent) =>
event.isComposing || composing() || event.keyCode === 229 || performance.now() - lastCompositionEnd < 100

const handleBlur = () => {
const cursor = currentCursor()
savedCursor = cursor
if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
closePopover()
setComposing(false)
syncComposingText()
}

const handleCompositionStart = () => {
setComposing(true)
syncComposingText()
}

const handleCompositionUpdate = () => {
syncComposingText()
}

const handleCompositionEnd = () => {
setComposing(false)
setComposingText(false)
lastCompositionEnd = performance.now()
// Input events are ignored while composing, so sync DOM -> state once now.
handleInput()
requestAnimationFrame(() => {
if (composing()) return
reconcile(prompt.current().filter((part) => part.type !== "image"))
Expand Down Expand Up @@ -865,7 +888,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
on(
() => prompt.current(),
(parts) => {
if (composing()) return
// The time window also covers the gap between compositionend and the
// next compositionstart when Japanese IMEs confirm segment by segment;
// a DOM rewrite landing there aborts the rest of the composition.
if (composing() || performance.now() - lastCompositionEnd < 100) return
reconcile(parts.filter((part) => part.type !== "image"))
},
),
Expand Down Expand Up @@ -970,7 +996,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return parts
}

const handleInput = () => {
const handleInput = (event?: InputEvent) => {
// During IME composition, never sync DOM -> state -> DOM: any programmatic
// DOM/selection mutation makes Safari abort the composition mid-way. Check
// the event too because Safari fires the first input event BEFORE
// compositionstart, when composing() is still false.
if (composing() || event?.isComposing || event?.inputType === "insertCompositionText") {
syncComposingText()
return
}
const rawParts = parseFromDOM()
const images = imageAttachments()
const cursorPosition = getCursorPosition(editorRef)
Expand Down Expand Up @@ -1527,6 +1561,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionUpdate={handleCompositionUpdate}
onCompositionEnd={handleCompositionEnd}
onFocus={handleFocus}
onBlur={handleBlur}
Expand All @@ -1543,7 +1578,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<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 }}
style={{ "padding-bottom": space, display: prompt.dirty() || composingText() ? "none" : undefined }}
>
{placeholder()}
</div>
Expand Down
58 changes: 48 additions & 10 deletions packages/session-ui/src/v2/components/prompt-input/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, 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"
Expand Down Expand Up @@ -52,6 +52,19 @@ export function PromptInputV2(props: PromptInputV2Props) {
if (!editor || !window.getSelection()?.isCollapsed) return
props.controller.onCursor(promptInputV2Cursor(editor))
}
const syncEditor = (target: HTMLDivElement) => {
const cursor = promptInputV2Cursor(target)
const prompt = parsePromptInputV2Editor(target)
const images = props.controller.parts().filter((part) => part.type === "image")
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}
// The prompt state stays empty while an IME composition is in flight (input
// events are ignored until compositionend), so the placeholder has to watch
// the composing text in the editor itself or it renders under it.
const [composingText, setComposingText] = createSignal(false)
const syncComposingText = (target: HTMLDivElement) =>
setComposingText(!!target.textContent?.replace(/[\n\u200B]/g, ""))
const mode = createMemo(() => state.mode)
const buttons = createMemo(() => ({
opacity: mode() === "normal" ? 1 : 0,
Expand All @@ -66,6 +79,9 @@ export function PromptInputV2(props: PromptInputV2Props) {
localInput = false
return
}
// Rewriting the editor DOM while an IME composition is active makes Safari
// cancel it mid-flight; the compositionend write-back reconciles state.
if (props.controller.imeActive()) return
renderPromptInputV2Editor(editor, parts)
})

Expand Down Expand Up @@ -159,26 +175,48 @@ export function PromptInputV2(props: PromptInputV2Props) {
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)
// Safari fires input (insertCompositionText) before compositionstart
// and on every composition keystroke; reacting here re-renders
// mid-composition and splits it into per-letter micro compositions.
// State is reconciled once by the compositionend write-back.
if (event.isComposing || props.controller.imeActive()) {
syncComposingText(event.currentTarget)
return
}
syncEditor(event.currentTarget)
}}
onKeyDown={(event) => {
// While composing (and shortly after compositionend) stay inert:
// dispatching here re-renders and makes Safari split the
// composition, and the confirming Enter must never submit.
if (props.controller.imeComposing(event)) return
if (props.controller.onKeyDown(event)) return
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (event.repeat) return
props.controller.submit()
}
}}
onKeyUp={updateCursor}
onPointerUp={updateCursor}
onCompositionStart={(event) => {
props.controller.onCompositionStart()
syncComposingText(event.currentTarget)
}}
onCompositionUpdate={(event) => syncComposingText(event.currentTarget)}
onCompositionEnd={(event) => {
props.controller.onCompositionEnd()
setComposingText(false)
syncEditor(event.currentTarget)
}}
onKeyUp={() => {
if (!props.controller.imeActive()) updateCursor()
}}
onPointerUp={() => {
if (!props.controller.imeActive()) updateCursor()
}}
onPaste={props.controller.onPaste}
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
/>
<Show when={!props.controller.value()}>
<Show when={!props.controller.value() && !composingText()}>
<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" }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,22 @@ export function createPromptInputV2Controller(input: {
return result.handled
}

let composing = false
let lastCompositionEnd = 0
// Safari fires compositionend BEFORE the confirming Enter keydown, so that
// keydown reports isComposing=false (keyCode 229 on Safari 26, 13 in older
// reports). Treat any key event within 100ms of compositionend as part of
// the IME confirmation. Compare with performance.now() rather than
// event.timeStamp so the guard never depends on the event clock source.
const imeComposing = (event: KeyboardEvent) =>
event.isComposing || composing || event.keyCode === 229 || performance.now() - lastCompositionEnd < 100
// True while a composition is in flight or just ended. The editor must stay
// completely inert in this window: dispatching state updates or touching the
// selection makes Safari abort the composition and split it into per-letter
// micro compositions, after which the confirming Enter arrives as a plain
// keyCode 13 that no guard can recognize.
const imeActive = () => composing || performance.now() - lastCompositionEnd < 100

const onKeyDown = (event: KeyboardEvent) => {
if (
state.mode === "normal" &&
Expand All @@ -201,7 +217,7 @@ export function createPromptInputV2Controller(input: {
type: "key.down",
key: event.key,
ctrl: event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey,
composing: event.isComposing,
composing: imeComposing(event),
ids: suggestions().map((item) => item.id),
empty: draft.state.prompt.every((part) => !("content" in part) || part.content.length === 0),
})
Expand Down Expand Up @@ -295,6 +311,15 @@ export function createPromptInputV2Controller(input: {
suggestions,
dispatch,
onKeyDown,
imeComposing,
imeActive,
onCompositionStart() {
composing = true
},
onCompositionEnd() {
composing = false
lastCompositionEnd = performance.now()
},
value() {
return draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("")
},
Expand Down
31 changes: 31 additions & 0 deletions packages/session-ui/src/v2/components/prompt-input/machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,37 @@ describe("prompt input v2 interaction machine", () => {
expect(selected.commands).toContainEqual({ type: "mention.add", item })
})

test("ignores Enter while IME composition is active", () => {
const state = {
...createPromptInputV2InteractionState(),
popover: { type: "context" as const, query: "", activeID: "first" },
}
const result = transitionPromptInputV2(
state,
{ type: "key.down", key: "Enter", ctrl: false, composing: true, ids: ["first"] },
persisted(),
)

expect(result.handled).toBeFalse()
expect(result.commands).toEqual([])
expect(result.state.popover).toEqual({ type: "context", query: "", activeID: "first" })
})

test("selects the active suggestion with Enter after composition ends", () => {
const state = {
...createPromptInputV2InteractionState(),
popover: { type: "context" as const, query: "", activeID: "first" },
}
const result = transitionPromptInputV2(
state,
{ type: "key.down", key: "Enter", ctrl: false, composing: false, ids: ["first"] },
persisted(),
)

expect(result.handled).toBeTrue()
expect(result.commands).toContainEqual({ type: "suggestion.select", id: "first" })
})

test("loops active popover items with arrow keys", () => {
const state = {
...createPromptInputV2InteractionState(),
Expand Down
Loading