Building a Cross-Platform Input Method When WeChat, Office, and Lark All Break the Cursor Rules

Aijie ShuFounder & Developer · FigurichUpdated16 min read

Almost every engineer who sets out to build a cross-platform input method or text utility starts with the same innocent assumption: how hard could replacing a string of text possibly be? You read what the user just typed, send it to your model, delete the old characters, and drop the converted text back in place.

Then we actually shipped UnimeType across WeChat, WPS, Microsoft Office, Lark, and VS Code—the apps people actually live in every day—and learned a humbling lesson. The hardest part of building a cross-platform input method is that you cannot trust what host apps tell you about cursor selection (Selection) or document length (Length). Give the exact same sentence containing an emoji and two languages to WeChat, Word/WPS, an Excel cell, Google Docs, VS Code, and the Ghostty terminal, and they will report wildly different cursor coordinates and length units. One counts in UTF-16 code units, another counts in extended grapheme clusters, a third cheerfully accepts your replacement range while secretly appending the text at the end, and a fourth swallows your Backspace events half a second late.

To a user, all of those low-level disagreements look like the exact same bug: "Why did Convert do nothing?", "Why did it print my Pinyin and the Chinese characters side by side?", or "Why does it work in WeChat but fail inside my terminal?" Here is a look under the hood at the real failures we hit across macOS IMKTextInput, iOS keyboard extensions, Android InputConnection, and Windows TSF—and how we built an evidence-driven state machine that refuses to corrupt your draft when an app lies.

An Input Method Does Not Own Your Document

To understand why text replacement breaks in such creative ways, you have to start with one unforgiving constraint: a third-party input method does not own the document you are editing. No matter which operating system you use, the input method is an outside guest peeking through a narrow platform contract and asking the host app for favors:

PlatformPrimary IME SurfaceWhat You Can Theoretically Ask the Host For
macOSIMKTextInput / NSTextInputClientselectedRange, length, attributedSubstring, insertText(_:replacementRange:)
iOSUITextDocumentProxyselectedText, documentContextBefore/AfterInput, adjustTextPosition, delete & insert
AndroidInputConnectiongetSelectedText, getTextBeforeCursor, deleteSurroundingText, commitText, getExtractedText
WindowsTSF ITfContext / ITfRangeGetSelection, GetText, ShiftStart/End, SetText

On paper, every platform hands you a clean three-part contract: read the selection, read the surrounding text, and replace a range. In practice, host compliance varies by orders of magnitude. Native editors like Xcode play by the book and expose accurate document-wide coordinates. Mobile keyboards on iOS and many Android chat boxes only show you a truncated window of characters right around the caret. Desktop chat apps like WeChat let you read the selection just fine, then silently ignore your replacementRange parameter when you write back. Certain terminal emulators report length == 0 no matter how many words you have typed on the command line, and Monaco-based editors like VS Code can even mirror the text before the cursor into the range after the cursor.

UnimeType's product design shines a harsh spotlight on every one of those cracks. Traditional Pinyin or Romaji IMEs hold your keystrokes inside an uncommitted pre-edit underline (marked composition) while you pick words one by one. UnimeType lets you type a complete mixed-language sentence straight into the text field as plain Latin text, then press an explicit action key to capture the scope, run the model, and replace the draft in place. If our understanding of selection or length drifts from the host's by even half a character at any step, you see it immediately on screen.

Why Not Just Use a Local Typing Buffer? And How Does Grammarly Do It?

Whenever we discuss this problem with other systems engineers, the first question they ask is almost always the same: "If host apps lie so often about selection and length, why not ignore the host and use the IME's own local typing buffer—the keystrokes the user just typed through your keyboard—as the ground truth?"

It is a great question. In fact, for completely blind hosts like the Ghostty terminal (which reports length() == 0 when nothing is highlighted), a local session buffer is a helpful clue when you type a line straight through from left to right. But in everyday editing, an input method's local typing buffer can never be trusted as unconditional ground truth, for three fundamental reasons:

  1. Out-of-band edits and cursor jumps never pass through the IME's keystroke stream. Halfway through typing a sentence, users click the mouse to fix a typo three words back, press Cmd + V to paste a URL, hit Cmd + Z to undo, or highlight and delete a clause with the trackpad. On most operating systems, mouse clicks, menu shortcuts, and clipboard pastes are consumed directly by the host application without notifying the IME which characters changed. If your IME blindly trusts its own typing buffer, its in-memory string is now completely out of sync with where the caret actually sits on screen.
  2. Host apps mutate text behind the IME's back. Type an opening parenthesis ( or backtick ` in VS Code or Notion, and the editor automatically inserts the closing ) after the cursor. Type @ in Slack or Lark and press Enter on an autocomplete popup, and the host replaces three ASCII letters with a rich-text mention token. Hit Tab in a terminal, and your shell expands a partial path into twenty characters. None of those host-generated characters ever entered the IME's keystroke buffer.
  3. Knowing what was typed still doesn't solve how to overwrite it inside the host. Even when your local typing buffer knows the exact 20 characters the user just typed, you still have to ask the host app to delete those 20 characters and insert the converted output. When WeChat ignores replacementRange or processes Backspaces asynchronously, replaying N blind Backspaces based on a local buffer length will chew into the wrong words the moment a cursor offset or async queue drifts. A local typing buffer is therefore only a session hint that must be strictly invalidated on navigation or focus changes—never a substitute for verifying the host's live state.

That leads directly to the second question: how do desktop writing and dictation tools like Grammarly Desktop, Raycast AI, TextExpander, or voice-to-text utilities handle cross-app text replacement? Across the industry, desktop tools rely on three alternative mechanisms—and each comes with serious trade-offs:

Industry ApproachRepresentative ToolsHow It Works Under the HoodWhere It Breaks Down
OS Accessibility Tree (AXUIElement / UIAutomation)Grammarly DesktopRuns as a background accessibility agent rather than an IME; queries window control trees (kAXValueAttribute, kAXSelectedTextRangeAttribute) and writes back via AXUIElementSetAttributeValueElectron and Chromium apps disable the accessibility tree by default; forcing it on (AXManualAccessibility) famously causes severe typing lag and high CPU in VS Code and Slack. Canvas-rendered apps (Google Docs canvas, GPU terminals) expose no text nodes at all, and many fields mark AXValue read-only.
Clipboard Hijacking (Cmd+C / Cmd+V + Pasteboard Swap)Raycast AI, TextExpander, desktop voice/dictation toolsSimulates Cmd+C (or Option+Shift+Left selection), overwrites the system clipboard (NSPasteboard), fires a synthetic Cmd+V, waits ~100ms, and attempts to restore the previous clipboardPollutes clipboard history managers (Raycast Clipboard History, Paste, Maccy, Windows Win+V) with half-typed drafts. Worse, restoring the old clipboard 50ms too early causes slow Electron apps to paste the user's old clipboard item instead of the new text.
Browser DOM InjectionGrammarly Browser ExtensionInjects content scripts directly into web <textarea> and contenteditable elementsOnly works inside browser tabs (useless in native or Electron desktop apps), and frequently collides with virtualized editors like Monaco, ProseMirror, Slate, and canvas-based Google Docs.

Even more importantly, iOS and Android sandboxes outlaw the first two approaches completely: a mobile keyboard extension cannot inspect the system Accessibility tree or synthesize Cmd+C / Cmd+V pasteboard swaps. Even Grammarly's own desktop engineering blog has documented that no single API works everywhere—forcing them to maintain per-app strategies and blocklists when Accessibility attributes fail or turn read-only.

Because UnimeType ships as a true cross-platform input method across macOS, Windows, Linux, iOS, and Android, we refuse to pollute your clipboard history with synthetic Cmd+V hacks, and we never blindly trust an unverified local keystroke buffer. Instead, we combine standard IME contracts (IMKTextInput, UITextDocumentProxy, InputConnection, TSF) with runtime evidence checks so that reading and writing stay fast, private, and predictable.

Four Operating Systems, Four Different Worldviews

Before we even get to individual apps, the four major operating systems disagree on what a document looks like.

On macOS, IMKTextInput looks closest to a true text editor. Selections are NSRange values measured in UTF-16 code units (matching NSString.length), you can request substrings by range, and insertText(_:replacementRange:) lets you overwrite a specific span. Yet macOS hosts routinely tell three lies: they report selectedRange as NSNotFound while a visible caret is blinking on screen; they return NSNotFound or 0 from length() while still returning valid substrings when queried by range; and worst of all, they accept insertText(_:replacementRange:) without returning an error while completely ignoring replacementRange and appending the new string at the caret. If you try to "probe" whether range replacement works by firing a test write in an app like WeChat, your probe looks like it succeeded while leaving nihao你好 mashed together in the chat box. That is why our macOS engine enforces a strict rule: no evidence, no blind write—we never run destructive range probes on untrustworthy hosts, and we verify that the original characters are cleanly replaced before committing.

On iOS, custom keyboard extensions never get arbitrary document ranges in the first place. Apple's UITextDocumentProxy only exposes selectedText (when the user has highlighted something) plus documentContextBeforeInput and documentContextAfterInput—both of which are local windows that the OS truncates at an arbitrary length. Full-document, no-selection paragraph conversion on iOS simply cannot share the same coordinate model as macOS. Instead, our iOS keyboard prioritizes any explicit selection unconditionally and bounds unselected actions strictly to the verified local input context around the caret. That way, even if iOS truncates a long note behind the scenes, we never delete half a paragraph by mistake.

Android's InputConnection looks more generous than iOS at first glance, offering getTextBeforeCursor(n), getSelectedText, and deleteSurroundingText / deleteSurroundingTextInCodePoints. But Android's app ecosystem is the wildest of the four. Many custom WebViews and chat inputs return empty or arbitrarily clipped strings from getTextBeforeCursor, or report selection state that lags behind what is visibly highlighted. Worse, classic deleteSurroundingText counts in Java char units (UTF-16 code units). If a sentence includes an emoji or surrogate pair and you delete by the wrong unit, you slice an emoji in half and leave a broken surrogate behind. On Android we align our deletion boundaries to full Unicode code points and verify context consistency right before committing—if the text around the cursor has drifted, we abort cleanly rather than blindly deleting characters and hoping for the best.

On Windows, Text Services Framework (TSF) gives you ITfContext selections and ITfRange shifts, which work well in standard Win32 and WPF controls. Yet when we tested unscoped paragraph capture inside VS Code and other Monaco-backed surfaces, we ran into a surreal bug: when expanding an empty selection both left and right from the caret, the host exposed a mirrored copy of the pre-caret run after the caret—turning nihao into the palindrome nihaooahin. On Windows we therefore bound empty-selection reads strictly to the left of the caret and verify that the text in range still matches the captured snapshot before calling SetText, ensuring we never clobber characters the user typed while the model was running.

Ten Inputs, Ten Different Personalities

Platform APIs are only layer one. The real minefield lives inside specific app bundles and custom text engines. On macOS, for instance, our application compatibility guide documents how common hosts behave differently under IMKTextInput:

Host ApplicationCommon Host QuirkHow UnimeType Protects Your Draft
WeChatIgnores replacementRange (appending instead) and processes Backspaces asynchronouslyAvoids broken range writes and verifies old text is cleared before inserting
WPS / Microsoft Office (Word, Excel)Excel splits single-click cell overwrite mode from double-click formula/in-cell editing; Word rich-text markers shift offsetsDistinguishes cell-level focus from in-cell caret editing and falls back to verified session buffers or explicit selection
Google Docs / Lark Docs / Tencent DocsCanvas-rendered editors hide a tiny 1×1px proxy <textarea> under the caret and never expose the paragraph to the OSUses continuous typing buffer when typing straight through, or prompts to highlight text when editing older lines
VS Code / Terminal / GhosttyVS Code mirrors text after the caret on empty selection; some terminals report length() == 0Bounds empty-selection reads to pre-caret text and uses verified session context
Xcode & standard native editorsHonors standard NSTextInputClient range contractsNative replacementRange overwrite

Looking at how individual apps fail explains why a single naive algorithm can never survive all of them.

Take WeChat on macOS. You type nihao and trigger Convert; the model returns 你好. If the IME calls insertText("你好", replacementRange: originalRange), WeChat accepts the call, drops replacementRange on the floor, and inserts at the end—turning your input into nihao你好. Worse yet, WeChat's input field processes Backspaces asynchronously: if an IME inserts 你好 before the asynchronous deletion finishes, late-arriving Backspaces chew the first character right off your freshly converted Chinese output.

Now switch over to Lark (Feishu), and you hit the exact opposite quirk: the underlying text clears immediately, while its reported selectedRange coordinate occasionally lags behind. Waiting blindly for the caret coordinate to catch up leaves the user staring at a blank input box for a full second. And in GPU-accelerated terminals like Ghostty, when no text is highlighted with the mouse, querying the host returns length() == 0. UnimeType handles these contradictory behaviors with runtime evidence checks—preventing WeChat from clipping characters and eliminating Lark's blank pause—while on traditional terminals that cannot safely verify context at all, we tell the user upfront when a selection is required rather than ever abusing uncommitted IME marked composition as a fake replacement channel, which would wreck shell line editing, tab completion, and @ file pickers.

How We Guard Scope and Length When Apps Misbehave

Out of all those battles came the core engineering invariants documented in our text scope rules.

First, an explicit non-empty selection always wins, and we never silently expand or swap it. Whether you trigger Convert, Polish, or Explain, if you highlighted five words, those exact five words define the scope. We never expand a highlighted phrase to the surrounding paragraph or swap it out for another buffer, because highlighting is how a user draws a trust and privacy boundary around what the model is allowed to touch.

Second, we preserve Markdown formatting and already-converted paragraphs. When your cursor is at the end of a Markdown list item - ming tian kan metrics and you trigger Convert without a selection, UnimeType automatically preserves the outer list marker - , heading #, blockquote > , or task box [ ] so your indentation and structure never get eaten by the model. And once a paragraph has been converted, if you keep typing a new Latin phrase at the end of that same paragraph and press Convert again, UnimeType incrementally replaces only the newly appended suffix rather than flickering the entire paragraph.

Third, we keep the different meanings of "length" strictly separated so emojis and multi-byte characters never get sliced in half:

Length ConceptUnderlying UnitWhat Goes Wrong If Mixed Up
Host Document Coordinates (macOS / Windows)UTF-16 code units (NSRange, TSF ranges)Treating a multi-byte emoji as length 1 shifts replacement offsets across the sentence
Language String LengthExtended grapheme clusters vs UTF-16Mixing string APIs slices composite emojis or rare CJK characters into broken surrogates
Mobile Deletion APIs (Android)UTF-16 code units vs Unicode code pointsDeleting by the wrong unit leaves half a surrogate pair behind at the cursor

By validating every host range against exact encoding boundaries before writing back, UnimeType aborts immediately whenever a host returns a truncated substring rather than operating on a half-sliced surrogate pair—and in diagnostic telemetry, we record only status outcomes without ever logging user text.

Why "Unable to Replace Text" Is a Safety Feature, Not a Crash

Putting all of those failure modes side by side gives you a clear view of what is happening under the hood when an editor misbehaves:

What You See on ScreenWhat Actually Happened Under the HoodHow UnimeType Protects Your Text
Pressing Convert does nothingEmpty selection + unreadable paragraph, or output equals inputShows an explicit No convertible text or Text unchanged status
Output becomes nihao你好 (Pinyin + Chinese)Host accepted insertText but ignored replacementRangeAvoids broken range write on that host and uses verified replacement
First character of converted text gets clippedHost processed Backspaces late, after new text was already insertedVerifies old text is cleared before inserting converted output
Input field goes blank for ~1s before text appearsHost cleared text immediately but lagged on selectedRangeCombines text and caret readiness checks to insert promptly
Palindrome duplication like nihaooahinEditor exposed a mirrored run after the caretRestricts empty-selection reads to valid pre-caret text only
Typing while waiting for Convert refuses to overwriteUser moved the caret, switched apps, or typed new words mid-flightRe-read guard aborts write-back and shows Text changed

So if you ever see a status pill that says Select text first in this editor, Text changed, or Unable to replace text (all covered in our status and troubleshooting guide), that is not random flakiness—it is the state machine slamming on the brakes because the host's selection or text changed while your request was in flight, or because the editor refused to prove what text was under the cursor.

Input method compatibility is unglamorous engineering: it is an endless stream of NSNotFound checks, UTF-16 surrogate boundaries, mirrored Monaco ranges, and late Backspace queues. But for a tool built around typing a complete thought in Latin letters and converting it in place with a single keystroke, those guards are the difference between a tool you trust with real work and a toy that eats your sentences. Whenever an app behaves badly, highlighting your sentence before pressing Convert is always the 100% bulletproof path—and when you don't highlight, our state machine is working behind the scenes to make sure we only overwrite your draft when the host's numbers actually add up.

Download UnimeTypeiOS, Android, macOS, Windows, Linux, and ChromeOS

View downloadsNext: Mixing Chinese and English Is Effortless Out Loud—Until You Have to Type It