macos: send all insertText commits as key events (#13817)

Partially addresses #13796. Extends #13222.

Previously, `insertText` commits without marked text were delivered via
`sendText`, which applies paste semantics and wraps the text in
bracketed
paste when the program enables it. macOS dictation and other input
methods often commit without marked text, so programs treated dictated
text as a paste: opencode collapsed it into a `"[Pasted ~N lines]"` chip
and Neovim applied paste-mode handling.

`insertText` is only invoked by input methods (IME, dictation, emoji
picker, character viewer); real paste operations use a separate path.
Every non-empty commit is now sent as a key event — the same path
already used for preedit commits since #13222 — so input method text
always arrives as typed input.

Typing is unaffected (the accumulator path returns earlier) and Cmd+V
pastes are unaffected. `committedPreeditTextAction` is renamed to
`committedTextAction` since it no longer only handles preedit commits.

Testing:

- 311 macOS unit tests pass.
- Manually verified on macOS 26: dictation into Opencode and Neovim
  arrives inline with no paste handling; emoji picker inserts inline;
  Chinese IME composition unchanged; dictation in Neovim normal mode now
  behaves as keystrokes, matching Terminal.app.

Notes:

- Dictated "new line" now matches Terminal.app behavior (no newline
  with typed-text semantics). The previous behavior came from the paste
  path preserving the newline; a follow-up could deliver it as an
  Enter keypress if desired.

AI usage: drafted with OMO + OpenCode + DeepSeek V4 Pro assistance;
reviewed, edited, and manually tested by the author.
This commit is contained in:
Mitchell Hashimoto
2026-08-15 14:26:19 -07:00
committed by GitHub

View File

@@ -1212,7 +1212,7 @@ extension Ghostty {
continue
}
_ = committedPreeditTextAction(action, text: text)
_ = committedTextAction(action, text: text)
}
if shouldReplayCommittedPreeditKey(translationEvent) {
@@ -1512,7 +1512,7 @@ extension Ghostty {
}
}
private func committedPreeditTextAction(
private func committedTextAction(
_ action: ghostty_input_action_e,
text: String
) -> Bool {
@@ -2074,7 +2074,6 @@ extension Ghostty.SurfaceView: NSTextInputClient {
func insertText(_ string: Any, replacementRange: NSRange) {
// We must have an associated event
guard NSApp.currentEvent != nil else { return }
guard let surfaceModel else { return }
// We want the string view of the any value
var chars = ""
@@ -2100,8 +2099,6 @@ extension Ghostty.SurfaceView: NSTextInputClient {
return
}
let hadMarkedText = hasMarkedText()
// If insertText is called, our preedit must be over.
unmarkText()
@@ -2113,14 +2110,11 @@ extension Ghostty.SurfaceView: NSTextInputClient {
return
}
if hadMarkedText, !chars.isEmpty {
// Send preedit commits as key events instead of raw text for
// keybind interpretation by programs.
_ = committedPreeditTextAction(GHOSTTY_ACTION_PRESS, text: chars)
return
// All committed text (IME, dictation, etc.) must be sent as key
// events so programs treat it as typed input, never as a paste.
if !chars.isEmpty {
_ = committedTextAction(GHOSTTY_ACTION_PRESS, text: chars)
}
surfaceModel.sendText(chars)
}
/// This function needs to exist for two reasons: