All posts

The Bug That Ate Right-Clicks: A macOS Selection Postmortem

·Terminal Candy ·5 min read

engineeringmacosappkitpostmortemterminal-candy

A user wrote in with a bug report that made no sense: in mini mode, text selection only worked in part of the window. Not "did not work" — worked, in a region, with an invisible boundary.

That is a strange shape for a bug. Selection is either wired up or it is not. A selection that works in a rectangle means something is intercepting clicks, and it is doing it geometrically.

This one took a while, and it turned out to be three separate bugs wearing a trench coat. Writing it down because two of them are traps any AppKit or SwiftTerm app can hit.

Bug one: a hidden view that was not hiding

Terminal Candy draws a skin — a Game Boy, a cassette deck, whatever art you loaded — and then draws a live terminal in a cutout on top of it. The view that does this is SkinFrameView, and it overrides hitTest so clicks in the cutout reach the terminal while clicks on the artwork drag the window.

Mini mode hides the skin. The window shrinks to a small plain terminal, the skin view gets isHidden = true, and everything should route to the terminal.

It did not, because our hitTest override never checked isHidden.

This is the trap, and it is a good one: AppKit's default hitTest skips hidden views for you. If you never override it, hidden views are invisible to the event system and you will never think about it. The moment you write your own hitTest, you have opted out of that behavior and inherited the responsibility. Nothing warns you. The view is invisible on screen and fully live to the mouse.

So in mini mode the hidden skin view was still there, still answering hit tests, still claiming every click that fell outside the stale cutout rectangle from the last skin you had loaded. That is the invisible boundary. It was the shape of a terminal window that was not on screen anymore.

override func hitTest(_ point: NSPoint) -> NSView? {
    if isHidden { return nil }   // the line that was missing
    // ...cutout logic
}

One line. Two evenings.

It ate right-clicks too, which is why the context menu was inconsistent over the terminal — same cause, same fix, and it is the reason 1.1.2 also shipped a proper right-click menu everywhere.

Bug two: selections vanishing during output

Separate report, same release: select some text while something is printing, and the highlight disappears instantly. Claude Code's spinner was enough to trigger it. So was tail -f.

This one is in the terminal layer. SwiftTerm's feedPrepare clears the current selection as part of getting ready to process incoming data. Reasonable in the general case — if the buffer scrolls, a selection anchored to buffer coordinates is not meaningful anymore.

Except it fires on any feed, including output that does not touch the selected region at all. An agent that redraws a spinner ten times a second was clearing your selection ten times a second. You could not select text while an agent was thinking, which is precisely when you want to copy an error message out.

The fix was to stop treating every feed as invalidating and preserve selections that the incoming data does not disturb.

Bug three: the mouse was never ours to begin with

The third one is the most interesting because it is not a bug in our code, it is a wrong default.

Terminals support mouse reporting: an app running inside the terminal can ask to receive mouse events itself. Vim uses it. htop uses it. Claude Code uses it. When it is on, a drag is not a text selection — it is an event handed to the program, and the program decides what it means.

We had allowMouseReporting = true, which sounds like the accommodating choice. The result was that dragging to select text inside Claude Code did not select text. It went to Claude Code, which interpreted it as its own selection, and showed its own "copied N chars" toast.

Users reported this as "selection is broken." They were right. It was working exactly as specified and the specification was wrong for the common case.

The change in 1.1.2: allowMouseReporting is now always off. A plain drag is always a native selection, every time, in every app. If you actually want to pass the mouse through to a TUI, hold ⌥ and drag.

The general principle, which I now believe fairly strongly: when a feature and a universal expectation conflict, the universal expectation wins by default and the feature gets a modifier key. Dragging to select text is not a preference. It is what a rectangle of text on a Mac does.

Bug four, which is why you test on the newest OS

While fixing the above, resizing the window with session tabs on screen started crashing on macOS 26. A CTFontDrawGlyphs abort inside the tab pills — a text-rendering path that had been fine for a year and became fatal on a new OS.

No cleverness here, just the reminder: a native app inherits the OS's rendering stack, and that stack changes underneath you. It is the tax you pay for not shipping Electron, and on balance I still think it is the right trade — but it is a real tax and it is paid in September.

What shipped

1.1.2 went out with all four fixed, plus some things that came out of staring at this code for a week:

  • Session tab pills now appear only when you have two or more sessions. One terminal means zero chrome.
  • Closing a window with a running process asks first, the way Terminal.app does. Idle shells still close silently.
  • A new menu bar icon — the Terminal Candy wink instead of a generic terminal glyph.

What I would do differently

Audit every hitTest override the day you write it. There are exactly two things AppKit does for free that you lose by overriding it — hidden views and alphaValue — and both fail silently and geometrically. That is the worst possible failure mode: no crash, no log, just a region of the window where the mouse behaves differently.

Treat "it works in part of the window" as a hit-testing bug immediately. I spent the first evening in the selection code. The bug was never in the selection code. The shape of the symptom told me where it was and I did not listen to it.

Distrust defaults that sound generous. allowMouseReporting = true reads as "support more things." It actually read to users as "selection is broken."

Terminal Candy is a native macOS terminal you can skin into anything — AppKit and SwiftTerm, Apple Silicon, no Electron. $10 once, 14 day trial. Try it, browse the skin gallery, and if you find something like the above, send it in — that is where 1.1.2 came from.

Keep reading