Desktop composer IME input and Enter key bugs

NousResearch/hermes-agent · cluster 19234 · apps/desktop

Data as of

9 open items. Fix merged in #39639. Check it covers your case.

The short answer

4open issues
5open PRs
63items grouped
Known?
Yes. 63 related items grouped here, 37 issues already closed.
Fix event
A fix was merged to main in PR #39639. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #39639, #86760. That is separate from the fix event.
Still open
4 issues and 5 PRs.

Multiple bugs and fixes related to IME composition (Chinese, Japanese, Korean) breaking the desktop composer's send button, Enter key behavior, and text syncing. Several fixes address preventing Enter from submitting during IME composition and keeping draft state accurate.

Open issues4

  • Issue#19234Scarf: Add option to send message with ⌘+Enter instead of Enter
  • Issue#38117Bug: typing Korean leaves last syllable in input
  • Issue#39107[Bug]: 我的hermes桌面版,聊天输入框,中文输入法输入的文字不管是中文还是英文按回车都无法发送,必须是英文输入法按回车键才能正常发送,不知道什么原因
  • Issue#39112[Bug]: 我的hermes桌面版,聊天输入框,中文输入法输入的文字不管是中文还是英文按回车都无法发送,必须是英文输入法按回车键才能正常发送,不知道什么原因

Open PRs5

  • PR#37603Fix desktop IME enter handling and gateway restart guards
  • PR#39246fix(tui): guard Enter submit against IME composition via timestamp
  • PR#39612[codex] fix(desktop): keep composer draft synced while typing
  • PR#39660fix(desktop): Enter key does not send message on Windows (false IME composing guard)
  • PR#53668fix(desktop): read live DOM text for Ctrl+Enter steer to avoid stale-state race

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #39639 fix(desktop): send on Enter from live editor text, not stale composer state

    merged to main

    files: apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx, apps/desktop/src/app/chat/composer/index.tsx

    Show the diff
    • apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx
      @@ -0,0 +1,189 @@
      +import { act, cleanup, fireEvent, render } from '@testing-library/react'
      +import { useRef, useState } from 'react'
      +import { afterEach, describe, expect, it, vi } from 'vitest'
      +
      +// No global setupFiles registers auto-cleanup, so unmount between tests —
      +// otherwise a second render() leaks the first editor and getByTestId('editor')
      +// matches multiple nodes.
      +afterEach(cleanup)
      +
      +// Faithful mirror of index.tsx's Enter wiring (handleEditorKeyDown's Enter
      +// branch + submitDraft), driven through REAL DOM keydown events on a
      +// contentEditable.
      +//
      +// Regression repro for #39630: pressing Enter right after typing (fast typing /
      +// IME) did nothing. The composer state (`draft` from useAuiState) and its
      +// derived `hasComposerPayload` lag the DOM by a render, so the keydown handler
      +// read empty state and either dropped the message, drained a queued prompt
      +// instead of sending, or (while busy) refused to queue. The fix reads the live
      +// editor text — `hasLivePayload` in the handler and a DOM re-sync at the top of
      +// submitDraft — so the just-typed text always wins.
      +//
      +// We model the race deterministically the way the IME repro does: mutate the
      +// editor's textContent WITHOUT firing an input event, so the React `draft`
      +// state stays stale while the DOM already holds the text.
      +function Harness({
      +  busy = false,
      +  queued = [],
      +  onSubmit,
      +  onQueue,
      +  onCancel,
      +  onDrain
      +}: {
      +  busy?: boolean
      +  queued?: readonly string[]
      +  onSubmit: (text: string) => void
      +  onQueue: (text: string) => void
      +  onCancel: () => void
      +  onDrain: () => void
      +}) {
      +  const editorRef = useRef<HTMLDivElement>(null)
      +  const draftRef = useRef('')
      +  // Mirrors `useAuiState(s => s.composer.text)` — updated only via setText, so
      +  // it lags the DOM until React re-renders (the source of the bug).
      +  const [draft, setDraft] = useState('')
      +  const attachments: unknown[] = []
      +
      +  const composerPlainText = (el: HTMLElement) => el.textContent ?? ''
      +
      +  const setText = (next: string) => {
      +    draftRef.current = next
      +    setDraft(next)
      +  }
      +
      +  const submitDraft = () => {
      +    const editor = editorRef.current
      +    if (editor) {
      +      const domText = composerPlainText(editor)
      +      if (domText !== draftRef.current) {
      +        draftRef.current = domText
      +        setDraft(domText)
      +      }
      +    }
      +
      +    const text = draftRef.current
      +    const payloadPresent = text.trim().length > 0 || attachments.length > 0
      +
      +    if (busy) {
      +      if (payloadPresent) {
      +        onQueue(text)
      +      } else {
      +        onCancel()
      +      }
      +    } else if (!payloadPresent && queued.length > 0) {
      +      onDrain()
      +    } else if (payloadPresent) {
      +      onSubmit(text)
      +    }
      +  }
      +
      +  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
      +    if (event.key === 'Enter' && !event.shiftKey) {
      +      event.preventDefault()
      +
      +      const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
      +      const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
      +
      +      if (!busy && !hasLivePayload && queued.length > 0) {
      +        onDrain()
      +
      +        return
      +      }
      +
      +      if (busy && !hasLivePayload) {
      +        return
      +      }
      +
      +      submitDraft()
      +    }
      +  }
      +
      +  // `draft` is read so the lint/compiler treats the stale-state mirror as live;
      +  // the assertions prove the handler never relies on it.
      +  void draft
      +
      +  return (
      +    <div
      +      contentEditable
      +      data-testid="editor"
      +      onInput={event => setText(composerPlainText(event.currentTarget))}
      +      onKeyDown={handleKeyDown}
      +      ref={editorRef}
      +      suppressContentEditableWarning
      +    />
      +  )
      +}
      +
      +describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
      +  it('sends the just-typed text on Enter even when composer state has not synced', async () => {
      +    const onSubmit = vi.fn()
      +    const { getByTestId } = render(
      +      <Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
      +    )
      +    const editor = getByTestId('editor')
      +
      +    // Fast typing: the DOM has the text but NO input event fired, so `draft`
      +    // state is still empty (the exact stale-state race).
      +    await act(async () => {
      +      editor.textContent = 'hello world'
      +      fireEvent.keyDown(editor, { key: 'Enter' })
      +    })
      +
      +    expect(onSubmit).toHaveBeenCalledWith('hello world')
      +  })
      +
      +  it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => {
      +    const onQueue = vi.fn()
      +    const onDrain = vi.fn()
      +    const onCancel = vi.fn()
      +    const { getByTestId } = render(
      +      <Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
      +    )
      +    const editor = getByTestId('editor')
      +
      +    await act(async () => {
      +      editor.textContent = 'urgent follow-up'
      +      fireEvent.keyDown(editor, { key: 'Enter' })
      +    })
      +
      +    expect(onQueue).toHaveBeenCalledWith('urgent follow-up')
      +    expect(onDrain).not.toHaveBeenCalled()
      +    expect(onCancel).not.toHaveBeenCalled()
      +  })
      +
      +  it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {
      +    const onCancel = vi.fn()
      +    const onSubmit = vi.fn()
      +    const onQueue = vi.fn()
      +    const { getByTestId } = render(
      +      <Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
      +    )
      +    const editor = getByTestId('editor')
      +
      +    await act(async () => {
      +      editor.textContent = ''
      +      fireEvent.keyDown(editor, { key: 'Enter' })
      +    })
      +
      +    expect(onCancel).not.toHaveBeenCalled()
      +    expect(onSubmit).not.toHaveBeenCalled()
      +    expect(onQueue).not.toHaveBeenCalled()
      +  })
      +
      +  it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
      +    const onDrain = vi.fn()
      +    const onSubmit = vi.fn()
      +    const { getByTestId } = render(
      +      <Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
      +    )
      +    const editor = getByTestId('editor')
      +
      +    await act(async () => {
      +      editor.textContent = ''
      +      fireEvent.keyDown(editor, { key: 'Enter' })
      +    })
      +
      +    expect(onDrain).toHaveBeenCalledTimes(1)
      +    expect(onSubmit).not.toHaveBeenCalled()
      +  })
      +})
    • apps/desktop/src/app/chat/composer/index.tsx
      @@ -814,7 +814,16 @@ export function ChatBar({
           if (event.key === 'Enter' && !event.shiftKey) {
             event.preventDefault()
       
      -      if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {
      +      // Decide from the DOM, not React state. `hasComposerPayload` is derived
      +      // from the AUI composer state, which lags the latest keystroke by a
      +      // render, so on fast typing / IME the just-typed text isn't in state yet.
      +      // Without the live read, a real message typed while prompts are queued
      +      // would drain the queue instead of sending. submitDraft() re-syncs and
      +      // sends the live editor text.
      +      const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
      +      const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
      +
      +      if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
               void drainNextQueued()
       
               return
      @@ -822,7 +831,10 @@ export function ChatBar({
       
             // Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
             // never a stray Enter after sending. With a payload, submitDraft queues it.
      -      if (busy && !hasComposerPayload) {
      +      // Gate on the live DOM payload (not the render-lagged composer state) so a
      +      // message typed fast / via IME while busy still reaches submitDraft() and
      +      // gets queued instead of being mistaken for an empty Enter.
      +      if (busy && !hasLivePayload) {
               return
             }
       
      @@ -1212,6 +1224,26 @@ export function ChatBar({
         }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
       
         const submitDraft = () => {
      +    // Source the text from the DOM editor, not React state. The AUI composer
      +    // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
      +    // render, so on fast typing or IME composition the final keystroke(s) may
      +    // not have synced yet — reading state here drops the message (Enter looks
      +    // like it does nothing; typing a trailing space only "fixes" it because the
      +    // extra input event forces a state sync). draftRef is updated on every
      +    // input event; refresh it from the editor once more to also cover an
      +    // in-flight keystroke that hasn't fired its input event yet.
      +    const editor = editorRef.current
      +    if (editor) {
      +      const domText = composerPlainText(editor)
      +      if (domText !== draftRef.current) {
      +        draftRef.current = domText
      +        aui.composer().setText(domText)
      +      }
      +    }
      +
      +    const text = draftRef.current
      +    const payloadPresent = text.trim().length > 0 || attachments.length > 0
      +
           if (queueEdit) {
             exitQueuedEdit('save')
           } else if (busy) {
      @@ -1222,12 +1254,12 @@ export function ChatBar({
             // busy guard for commands that genuinely need an idle session (skill
             // /send directives).  Queuing them would make every slash command wait
             // for the current turn to finish, which is how the TUI never behaves.
      -      if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
      -        const submitted = draft
      +      if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
      +        const submitted = text
               triggerHaptic('submit')
               clearDraft()
               void onSubmit(submitted)
      -      } else if (hasComposerPayload) {
      +      } else if (payloadPresent) {
               queueCurrentDraft()
             } else {
               // Stop button (the only way to reach here while busy with an empty
      @@ -1235,10 +1267,10 @@ export function ChatBar({
               triggerHaptic('cancel')
               void Promise.resolve(onCancel())
             }
      -    } else if (!hasComposerPayload && queuedPrompts.length > 0) {
      +    } else if (!payloadPresent && queuedPrompts.length > 0) {
             void drainNextQueued()
      -    } else if (draft.trim() || attachments.length > 0) {
      -      const submitted = draft
      +    } else if (payloadPresent) {
      +      const submitted = text
             triggerHaptic('submit')
             resetBrowseState(sessionId)
             clearDraft()
  • #86760 fix(desktop): consolidated IME composition handling — Enter guards, keyCode 229, stale-flag self-heal, keybind combos

    merged to main

    files: apps/desktop/src/app/chat/composer/enter-stale-ime-flag.test.tsx, apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx, apps/desktop/src/app/chat/composer/index.tsx, apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx, apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx, apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx, apps/desktop/src/components/onboarding/flow.tsx, apps/desktop/src/components/onboarding/index.tsx, apps/desktop/src/lib/keybinds/combo.test.ts, apps/desktop/src/lib/keybinds/combo.ts, contributors/emails/[redacted:email], contributors/emails/[redacted:email], contributors/emails/[redacted:email]

  • #86761 fix(tui,dashboard): consolidated IME input fixes — Korean/Telex recomposition, return-burst commits, PTY composition forwarding

    merged to main

    files: contributors/emails/[redacted:email], contributors/emails/[redacted:email], contributors/emails/[redacted:email], contributors/emails/[redacted:email], contributors/emails/[redacted:email], ui-tui/packages/hermes-ink/src/ink/parse-keypress-drop-probe.test.ts, ui-tui/packages/hermes-ink/src/ink/parse-keypress-noregress.test.ts, ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts, ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts, ui-tui/src/__tests__/imeVietnameseTelex.test.tsx, ui-tui/src/__tests__/textInputReturnBurst.test.ts, ui-tui/src/__tests__/textInputSubmitClear.test.tsx, ui-tui/src/components/textInput.tsx, web/src/lib/pty-composition.test.ts, web/src/lib/pty-composition.ts, web/src/pages/ChatPage.tsx

14 PRs closed without merging

From the close comments: done elsewhere 4 · closed without comment 3 · superseded by another PR 3 · merged via another change 2 · other 2

  • #37487 fix(desktop): prevent Enter from submitting during IME composition · merged via another change → #86760
  • #38001 fix(desktop): ignore IME composition enter · other
  • #38245 fix(tui): clear input after Korean IME submit · merged via another change → #86761
  • #38785 fix(desktop): ignore IME composition Enter shortcuts · superseded by another PR
  • #38968 fix(desktop): read composer text from DOM on Enter submit to avoid stale state · closed without comment
Show 9 more
  • #39210 fix(desktop): prevent message truncation in Windows composer · other → 8b84d82, #39639, #39089
  • #39230 fix(tui): guard Enter submit against IME composition via timestamp · closed without comment
  • #39435 fix(desktop): commit IME-finalised text on compositionend so Enter submits · done elsewhere → #39614
  • #39460 fix(desktop): use draftRef for hasComposerPayload to fix stale send-button and truncation · done elsewhere → 00694b9
  • #39647 fix(desktop): reconcile composer draft on compositionend for IME reliability · closed without comment
  • #39938 fix(desktop): sync contentEditable state before submit (#39872) · superseded by another PR → 8b84d82
  • #40148 fix: send button not switching from voice button during IME composition · done elsewhere → 8e629b9
  • #40657 fix(desktop): flush DOM to draft before submit to avoid IME character drop · done elsewhere → 8b84d82
  • #53660 fix(desktop): steer from live composer text · superseded by another PR → #53668, #53659
Closed issues · 37
  • Closed#37483[Bug]: Desktop app: Enter key sends message during IME composition (Japanese, Chinese, Korean, etc.)
  • Closed#38821bug: Desktop composer fails to send CJK/Chinese text without appending a trailing digit
  • Closed#38826Hermes Desktop App: Chinese IME input broken — pressing Enter submits empty/incomplete text, requires Shift+Enter
  • Closed#38883[Bug]: Desktop chat send button hidden after IME composition (Chinese/Japanese/Korean input)
  • Closed#38923Desktop: Enter key doesn't submit on first press — requires Shift+Enter first
  • Closed#39025Windows desktop: Enter does not submit Chinese IME text because rich composer draft state can stay stale
  • Closed#39089[Bug]: Desk app message truncation — content cut to random length before reaching CLI
  • Closed#39195[Bug]: Hermes Desktop TUI 中文输入法 IME 状态下按 Enter/Return 直接提交,而非确认候选词
  • Closed#39231Desktop composer: send button not shown during CJK IME composition
  • Closed#39436[Bug]: Desktop chat composer send button hidden until space is typed, messages sometimes truncated
  • Closed#39473Bug: Desktop app input truncates text when typing (paste works fine)
  • Closed#39534[Bug]: v0.15.1 96cd37e Hermes Desktop Windows cutted off chinese prompt in chat window
  • Closed#39538[Bug]: Desktop composer drops or fails to send CJK IME text on Enter
  • Closed#39560[Bug]: Desktop: trailing characters lost when sending message without punctuation
  • Closed#39620Japanese IME composition does not work correctly in Hermes Desktop composer on Windows
  • Closed#39630Desktop] Enter key doesn't send message — must add trailing space
  • Closed#39636Vietnamese Telex IME drops characters & tone marks in Hermes Desktop composer (macOS)
  • Closed#39649Desktop composer Enter key does not send message on Windows
  • Closed#39651[Bug]: IME composition causes send button to show as voice button + text truncation on submit
  • Closed#39786[Bug]: Chinese input gets truncated (last 1-2 characters are swallowed) in the input field
  • Closed#39872Desktop composer can submit stale/empty/truncated hand-typed text on immediate Enter
  • Closed#39908Bug: Cannot send messages with pure Chinese characters in Desktop — requires punctuation to trigger send
  • Closed#40142# Bug Report: Hermes Agent 桌面端输入截断
  • Closed#40145[Bug]: Bug Report: Hermes Agent 桌面端输入截断
  • Closed#40146Desktop app: Send button doesn't switch from voice button when typing Chinese (IME composition)
  • Closed#40226Desktop app: Chinese IME input breaks composer - text not synced, Enter doesn't send
  • Closed#40311[Desktop] Pure CJK/Chinese text in composer hides submit button (IME composition state not synced)
  • Closed#40633Desktop Korean IME input drops the final character unless a trailing space is typed
  • Closed#42186IME composition truncates submitted text — onCompositionEnd doesn't sync draft
  • Closed#42213[Desktop][Windows] 中文输入时按 Enter 发送消息,只发出前半句
  • Closed#44008[Desktop App] IME/中文输入法导致输入框按钮状态异常:无法提交 & 文字被截断
  • Closed#44217Composer IME composition bug - 中文输入法回车吞字
  • Closed#47931[Desktop] IME composition Enter key incorrectly sends message instead of confirming English input
  • Closed#48337Desktop: Chinese IME composition Enter prematurely submits message on macOS
  • Closed#51403[Bug] Korean IME: trailing character stuck in input and double-Enter required to send
  • Closed#53659Desktop composer Ctrl+Enter can miss freshly typed steer text
  • Closed#54144[BUG] Chinese IME: Enter key triggers message submit during IME composition
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.04: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#39639, #86760, #86761
Closed issues
37
Attempts
14 PRs closed without merging
Fix link checked
15 Aug 2026

For your agent

The same facts, typed.