From 5f93b3cd796964ce241a1e94436b4dbe9c96b22c Mon Sep 17 00:00:00 2001 From: cuiko Date: Fri, 5 Jun 2026 21:32:21 +0800 Subject: [PATCH] fix(monitor): Only switch input method for the frontmost app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AX window, focused-element, and title notifications fire even while an app is in the background. A terminal whose foreground program animates its title — Claude Code's braille spinner being the worst offender — emits a steady stream of these events, and handleWindowFocus acted on every one. The result: a backgrounded terminal's rule reached across and switched the input method of whatever app was actually in front, so typing in Obsidian (or a browser) kept getting yanked to the terminal's configured language. Guard handleWindowFocus so it ignores events whose app isn't frontmost, and re-check frontmost inside the debounce timer in case focus moved during the 0.15s window. activated stays unguarded since an activation notification is itself the signal that the app is now frontmost. --- Sources/TermIMS/FocusMonitor.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Sources/TermIMS/FocusMonitor.swift b/Sources/TermIMS/FocusMonitor.swift index 2e2ec9a..038af53 100644 --- a/Sources/TermIMS/FocusMonitor.swift +++ b/Sources/TermIMS/FocusMonitor.swift @@ -393,6 +393,14 @@ class FocusMonitor { private func handleWindowFocus(_ bid: String) { guard enabled else { return } + // AX window / focused-element / title notifications also fire while an + // app is in the *background* — most painfully for a terminal whose + // foreground program animates its title (e.g. Claude Code's braille + // spinner). Without this guard those background ticks would let the + // terminal's rule reach across and switch the input method of whatever + // app is actually in front (Obsidian, a browser, …). Only act when the + // event's app is the one the user is currently looking at. + guard isFrontmost(bid) else { return } if isTerminalWithRules(bid) { debouncedResolve(bid) } else { @@ -406,12 +414,20 @@ class FocusMonitor { termDebounce?.invalidate() termDebounce = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak self] _ in guard let self, self.enabled else { return } + // Re-check at fire time: during the debounce the user may have + // switched away from the terminal (e.g. to Obsidian), and we must + // not yank the now-frontmost app's input method. + guard self.isFrontmost(bid) else { return } if let id = self.resolveInputSource(for: bid) { selectInputSource(id) } } } + private func isFrontmost(_ bid: String) -> Bool { + NSWorkspace.shared.frontmostApplication?.bundleIdentifier == bid + } + @objc private func launched(_ n: Notification) { guard let bid = bundleID(from: n) else { return } let store = RuleStore.shared