---
title: "Mouse support"
description: "The wheel moves the cursor, a click selects a row, a double click focuses a session, and the mouse back button brings you back."
canonical: "https://agent-manager.dev/writing/mouse-support/"
last-updated: "2026-09-22"
section: "Writing"
source: "agent-manager"
---

# Mouse support

A terminal UI has a key for everything, so a pointer looks like decoration. The reason to build one was that the pointer was already doing something, and what it did was wrong.

Thank you to [fruch](https://github.com/fruch), who wrote the interaction model in [pull request 475](https://github.com/YoanWai/agent-manager/pull/475). What follows is what it does, and why it is built this way.

The manager runs every agent session as a tmux pane behind one list. Keys move the cursor, open a session, fold a group, resize the split. None of that needed a mouse. What needed attention was the wheel, because a terminal will act on a wheel notch whether or not the application asked for it.

Alternate scroll is the mechanism. While the alternate screen is up, a terminal with DECSET 1007 enabled turns each wheel notch into an arrow key and sends it as input. The manager read those arrows the only way it could, as cursor movement. Scrolling the list walked the selection, and the session your next keystroke would reach was no longer the one you thought you had picked.

![The wheel moves the cursor, a click selects a row, a double click focuses it, a click on the list comes back, the divider drags, and a click on the messages card opens the messages panel.](/assets/demo-mouse.gif)

_Fig. 1mouse · select, focus, drag, open_

## The wheel was already typing

An arrow key synthesised by the terminal is indistinguishable from an arrow key a person pressed. There is no flag on it and no way to ask where it came from, so the fix was to stop the terminal from making them.

`internal/ui/altscroll.go`

```go
// DECSET 1007 (alternate scroll) makes the terminal translate wheel events
// into arrow keys while the alt screen is up. The manager turns it off: the
// arrows are indistinguishable from real ones, so a wheel notch walked the
// session cursor.
const altScrollOff = "\x1b[?1007l"
```

That left the wheel inert in the list, which was the honest state to sit in while the real handling was written. Mouse reporting carries the wheel itself now, as an event the application can read, refuse or act on. Every mouse event is consumed rather than passed along, so an outer terminal or an outer tmux never scrolls the manager off its own screen while you are looking at it.

## A click needs the map the frame drew

Reporting a click is the easy half. The hard half is answering what was under it. The list had no row geometry to test against, because nothing had ever needed one. Rows are one line tall in compact density and two in comfortable. A search field, filter badges and an archive count sit above them and push everything below down. The window scrolls independently of the cursor.

Recomputing that layout inside the mouse handler would work until the day the rail gains a row, and then it would be wrong in a way nobody notices for a week. So the rail records what it painted, once per frame, off the final line slice.

`internal/ui/listview.go`

```go
func (m *Model) recordRailHits(lines []contentLine) {
    m.railHits = m.railHits[:0]
    for _, line := range lines {
        m.railHits = append(m.railHits, line.row-1)
    }
}
```

`railHits` maps every painted line to the row a click there selects, and holds `-1` for chrome that cannot be selected. The handler reads the map instead of the layout, and it still checks the row against the current list, because a poll can rebuild the rows between the frame that painted and the press that arrives.

`internal/ui/split.go`

```go
idx := y - y0
if idx >= len(m.railHits) {
    return 0, false
}
row := m.railHits[idx]
// A rebuild between the paint and the press can shrink m.rows under
// the hits this frame recorded.
if row < 0 || row >= len(m.rows) {
    return 0, false
}
```

The focused pane already worked this way, recording its box at paint time. The rail borrowed the pattern rather than inventing a second answer to the same question.

## The second press has to find the same row

A double click focuses a session, or folds a group. Two presses inside 400 milliseconds make the pair. The obvious way to remember the first one is to keep its row index, and the obvious way is wrong here. The list refreshes on a poll, sessions finish, groups fold, rows are inserted and removed. Index 4 in the first press and index 4 in the second can be two different sessions.

`internal/ui/split.go`

```go
// Matched on the row's identity: the poll rebuilds m.rows between
// the presses, so one index can name two different rows.
key := rowKey(m.rows[row])
double := !m.listClickAt.IsZero() && m.listClickKey == key &&
    time.Since(m.listClickAt) < multiClickWindow
```

The cursor moves to the row under the pointer before either gesture runs, so a wheel notch or a key pressed between the two presses cannot leave the action pointing somewhere else. A matched pair is then consumed, so a third press opens a new run instead of firing again.

## A press inside a session is two gestures at once

Focus mode puts you inside the agent's own pane, and some agents track the mouse themselves. A press there can mean two incompatible things. It can be a click the application under the pane should receive, or it can be the start of a drag that selects text to copy. The press alone does not say which.

So the press is parked, and whatever comes next decides. Motion away from the cell turns it into a selection from where it started. A release on the same cell turns it into a click and forwards it. A release on a different cell with no motion in between is a drag from a terminal that reports no motion, so it selects and copies.

A link gets one more answer. The manager opens it, because the terminal's own link opener cannot reach through the mouse claim while the application holds it, and the application has no opener of its own. Holding alt forces an entire gesture through to the pane, drags included, for the cases where you want the application to have it.

## The divider arms on the press

Resizing the split from the keyboard arms a resize mode first, which takes the keyboard with it until you commit or cancel. A drag cannot borrow that. A press whose release lands outside the window, or never lands at all, would strand the list in a mode nobody can see. So a mouse drag arms the drag alone and leaves the keyboard where it is.

A press and release on the seam with no motion between them is a click, not a resize. Reading it as a resize would persist a ratio nobody dragged to and reflow every live pane for a frame that never changed.

## Giving the mouse back

Mouse reporting has a cost, and it is paid by people who never asked for it. While it is on, the terminal stops doing native click-drag selection across the rail and the detail column, so copying a session name out of the list with the mouse stops working the way it always had.

Settings carries a mouse row for that reason. It is on by default, and only an explicit off is stored. Turning it off releases mouse reporting and hands the columns back to the terminal. Focus mode keeps its own forwarding either way, because that pane's mouse handling predates the setting and people rely on it.

One more seam sits between the two worlds. A press on a rail row while a session is focused leaves focus so that the same click selects the row, rather than asking for a keystroke first and a click after. The pane's blank tail, the chrome and a full screen session all keep the keyboard.

## What shipped

All of it is in v0.38.0. [Pull request 475](https://github.com/YoanWai/agent-manager/pull/475) closed most of [issue 110](https://github.com/YoanWai/agent-manager/issues/110). [Chiyo-no-sake](https://github.com/Chiyo-no-sake) made clicks pass through to a focused agent in [pull request 272](https://github.com/YoanWai/agent-manager/pull/272). Double click to focus, and the mouse back button to leave, came after in [pull request 526](https://github.com/YoanWai/agent-manager/pull/526).
