All posts

8 min read

Why your link click did nothing

The hover label in tuios said shift+click to open a link, and in most terminals shift+click never reached tuios. The fix moved the click to ctrl, sent every link to the outer terminal as OSC 8, and then got the Mac wrong once before it got it right.

GGGaurav Gosain

Hover a link in a tuios pane and a label appears under the pointer. It shows where the link goes, and before #418 it ended with shift+click to open. In most terminals, shift+click did nothing at all.

The click never arrived. This post is about who a click belongs to, the frame that hid every link from the outer terminal, and a label I changed for the Mac on a theory and changed back after a test.

Who owns a click

tuios reads the mouse. It asks the outer terminal for mouse reports, and the terminal then sends each click to tuios as an escape sequence instead of using it itself. That is how you drag a divider or click a pane to focus it.

Every terminal keeps one way out of this. In xterm, shift is the bypass modifier: a shift+click goes to the terminal, not to the program, so you can still select text while a program tracks the mouse. Most terminals copied that rule. The commit that fixed the gesture, b281f0bb, puts it in one line: "Shift is the xterm bypass modifier, so most terminals never pass shift+click on while a program reports the mouse."

So tuios had picked the one modifier that the outer terminal takes for itself. The comment above the old code even gave the reason it was chosen:

// Shift is the terminal's own "this click is mine, not the program's"
// modifier, which xterm has meant by it for decades, so it is the one
// modifier a user already expects to reach past a program that is tracking
// the mouse.

That reasoning is right about what shift means, and wrong about where the click goes. "Mine" means the terminal's. The table of terminals in the configuration docs now lists eight. In seven of them, shift+click never reaches tuios.

Ctrl+click, on release

Ctrl+click reaches tuios in all eight. The catch is that ctrl+drag already had a job: it moves a pane. A press cannot tell which one the user means. The release can.

On a ctrl press over a link, tuios notes the link and waits. If the pointer moves past the drag threshold, it is a drag and the pane moves. If it does not, the release opens the link:

if o.CtrlDragPending {
	o.CtrlDragPending = false
	// The press was on a link and nothing moved far enough to be a
	// drag, so this was a ctrl+click on the link.
	if url := o.CtrlClickLink; url != "" {
		o.CtrlClickLink = ""
		if o.CtrlDragIndex >= 0 && o.CtrlDragIndex < len(o.Windows) {
			o.FocusWindowFromClick(o.CtrlDragIndex, o.DragStartX, o.DragStartY)
		}
		return o, o.OpenLink(url)
	}
	return o, nil
}

Shift+click still works in the terminals that pass it on. A new setting, appearance.link_click, picks the gestures: both (the default), ctrl, shift or off.

The same commit reports a failed open. tuios watches the opener's exit for 3 seconds. If the opener fails, tuios shows a message and puts the URL on the clipboard. Under ssh, tuios does not start the system opener, because it would open the browser on the remote machine. On a Linux machine with no display there is no desktop to open it on. In both cases tuios copies the URL instead.

The click was half of it. The other half was in the frame.

tuios draws each pane into its own frame, so the outer terminal sees tuios's output and never the program's. A program can mark a link with OSC 8, an escape sequence that says "these cells are a link to this address". ls --hyperlink, compilers and agent CLIs do it, and the text on screen is often not the address. Most terminals can open an OSC 8 link on their own, with a click of their own.

They could not open one in a focused tuios pane, because that pane's frame dropped the OSC 8 marks. 073e0b08: "A focused pane dropped OSC 8 from the frame, so the outer terminal's own link handling never saw a labelled link's target."

Now the cell loop that draws a focused pane writes every link as OSC 8:

  • A link the program marked keeps the program's own address and its id= parameter. The id= is how OSC 8 says that two runs of cells are one link.
  • A bare URL that tuios finds in plain text gets OSC 8 too, with an id= of tuios's own. A URL that wraps onto the next row is then one link to the outer terminal, not two halves.

The id of a bare URL is a hash of the pane, the address and the cell where it starts:

func bareLinkID(windowID, url string, at linkCellRef) string {
	h := fnv.New64a()
	h.Write([]byte(windowID))
	h.Write([]byte{0})
	h.Write([]byte(url))
	h.Write([]byte{0, byte(at.X), byte(at.X >> 8), byte(at.Y), byte(at.Y >> 8)})
	return "tuios-" + strconv.FormatUint(h.Sum64(), 36)
}

The position is in the hash so that two copies of the same address on screen stay two links.

Finding bare URLs in every frame could be expensive, so the cell loop does not look for them on every line. It watches for :// as it reads the cells. Only a line that holds one is read as text and drawn again with its links. BenchmarkRenderTerminalReal/focused, at 207x55, went from about 347 us to 343 us, with allocations unchanged.

Three smaller bugs on the way

Three more fixes went in with it.

A URL cut by the edge of the pane. A long bare URL wraps over several rows. tuios read it from the rows on screen only. Scroll back to the head of a long URL and click it, and tuios opened the address cut at the pane's last row. Click the tail of a URL whose head had scrolled into the history, and it was not a link at all. 6f1f1ab8 follows the wrapped line past both edges, through the screen and the history, up to 16 rows each way. Only the underline stops at the edge.

An empty URI that kept its id. A program closes an OSC 8 link with an empty address. tuios's emulator cleared the address and kept the parameters, so the next link inherited the old id=. f7916166 adds a conformance case for it:

{
	// An empty URI closes the link whatever parameters come with it.
	name: "an empty URI with parameters closes the link",
	cols: 10,
	in:   "\x1b]8;id=n1;" + url + "\x07a\x1b]8;id=n1;\x07b",
	want: "ab",
	cells: []cellWant{
		{x: 0, y: 0, content: "a", link: ptr(url)},
		{x: 1, y: 0, content: "b", link: ptr(""), linkParams: ptr("")},
	},
},

Two detectors that disagreed. Hints mode and the pointer each had their own way to find a URL. The hints one ran to the next space, so a markdown badge was one long hint, and /p in </p> was a path. 35b6345d gives both one detector. A URL now stops at a space, a quote, a backtick or an angle bracket. A ) or ] that the URL did not open ends it, so a Wikipedia URL keeps its brackets and a badge splits at the right place.

The tests

The end-to-end tests are in e2e/tui/link_open_test.go. They start a real tuios with appearance.link_opener set to a script that appends its argument to a file. A link that opened is a line in that file. A link that tuios refused is no line. The file is the test's artifact.

The pane prints a fixture where every label differs from its target, so a test that opened the visible text would fail. The test then sends clicks the way the outer terminal reports them, in SGR with the modifier bits set:

col, row := mustFind(t, term, "click here")
mouseClick(t, term, col+3, row, tuitest.MouseLeft, tuitest.ModCtrl)
waitOpened(t, record, []string{linkTarget}, "ctrl+click on an OSC 8 label")

After the clicks, the test reads the bytes tuios sent to the outer terminal and checks for an OSC 8 sequence for each of four links: the labelled one, the id= one, a bare URL and a wrapped bare URL.

Each fix has a negative control in e2e/tui/NEGATIVE_CONTROLS.md: cut one call site, build, run the test, and watch it fail for the right reason. On the tree before the change, the first click fails with "the opener was started with [], want [https://example.com/real-target]". With the OSC 8 transition cut from the cell loop, all four OSC 8 checks fail. With javascript added to the schemes the opener may get, the record holds the script address.

One row in that file is not a control. TestLinkOpensFromARehydratedPane passes on both builds, because a daemon snapshot always carried OSC 8 targets. The file lists it as "guard, not a control", which is the honest name for a test that has never failed.

Then I got the Mac wrong

All of that went in with #418. The same day I merged #420 (d9c34874), which changed the label on a Mac to name cmd+click. Its reasoning:

macOS reads ctrl+click as a right click, and Terminal.app and iTerm2 open their own menu instead of passing it on. Every link reaches the outer terminal as OSC 8, so cmd+click opens it there.

Each step sounds right. The frame does carry every link as OSC 8 now, and cmd+click is how a Mac terminal opens a link. I did not test it on a Mac.

#426 records the test that followed, on macOS 27, in Ghostty, kitty, WezTerm and Terminal.app. Ctrl+click opened the link through tuios in all four. Cmd+click opened it in none of them. None of the four opens an OSC 8 link on cmd+click while the program reports the mouse, and tuios is such a program. The label sent Mac users to the one gesture that does nothing.

#426 removes the Mac case. The label names ctrl+click on every platform, or the gesture link_click sets. The configuration docs say that ctrl+click works on macOS and cmd+click does not.

What the suite could not see

The end-to-end suite could not have caught #420. It sends mouse reports to tuios the way a terminal does, and cmd is not part of the mouse protocol. A cmd+click never reaches tuios, so its fate is decided inside the outer terminal, on a machine the suite does not run. The only test for that is a person with a Mac and four terminals.

The first bug and the last one have the same shape. Both times I reasoned about what a modifier means and did not check where the click goes. Shift means "the terminal's", and so the terminal kept it. Cmd means "open a link" on a Mac, but not while a program reports the mouse. The click that works is the one the outer terminal hands over, and you find it by clicking.