detached 211b2e1

fix(daemon): gate mouse input on notices overlay activity

A click while the notifications history modal was open fell through to
floating-pane hit-testing, title-bar focus changes, and terminal mouse
forwarding, because handleMouse's overlay guard omitted the notices
overlay. The modal has no mouse handler of its own, so the pane behind
it would silently react to clicks on the modal.
commit
211b2e1117de537435e89bf8b6185e19496284ff
author
brice · · 2026-07-20 13:40 UTC
parent
1a97012
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +36 -1
M internal/usecase/daemon/input.go +1 -1
@@ -35,7 +35,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
3535 frameEvent := ev
3636 ac.initOverlays()
3737 rt := ac.overlays
38- if rt.promptActive() || rt.paletteActive() || rt.pickerActive() {
38+ if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() {
3939 return
4040 }
4141 sess := ac.currentSession()
@@ -35,7 +35,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
35 frameEvent := ev35 frameEvent := ev
36 ac.initOverlays()36 ac.initOverlays()
37 rt := ac.overlays37 rt := ac.overlays
38- if rt.promptActive() || rt.paletteActive() || rt.pickerActive() {38+ if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() {
39 return39 return
40 }40 }
41 sess := ac.currentSession()41 sess := ac.currentSession()
M internal/usecase/daemon/input_test.go +35 -0
@@ -1444,6 +1444,41 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
14441444 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
14451445 }
14461446
1447+// TestMouseGatedWhileNoticesOverlayActive guards against a click on the
1448+// notifications modal falling through to pane hit-testing underneath it: the
1449+// modal has no mouse handler of its own, so without the gate a click would
1450+// silently retarget focus and forward SGR bytes to whichever pane sits under
1451+// the modal's coordinates.
1452+func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
1453+ p1 := portsmocks.NewMockPTY(t)
1454+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1455+ p2 := portsmocks.NewMockPTY(t)
1456+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1457+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1458+ d.procComm = nil
1459+ tb := sess.activeTab()
1460+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1461+ p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1462+ tb.mu.Lock()
1463+ tb.size = domain.Size{Cols: 41, Rows: 5}
1464+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1465+ tb.tree.Focus = "pane-1"
1466+ tb.panes["pane-2"] = p2pane
1467+ tb.mu.Unlock()
1468+
1469+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
1470+ d.enterNotices(sess, ac)
1471+ awaitFrame(t, sends, ports.MsgOutput)
1472+ require.True(t, ac.overlays.noticesActive())
1473+
1474+ // Same coordinates that TestMouseHitTestFocusesPaneAndTranslatesSGRColumns
1475+ // proves would otherwise focus pane-2 and forward the SGR report to it.
1476+ d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1477+
1478+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus, "mouse must not reach pane hit-testing while the notices overlay is open")
1479+ require.True(t, ac.overlays.noticesActive(), "mouse click must not close the notices overlay")
1480+}
1481+
14471482 func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
14481483 p1 := portsmocks.NewMockPTY(t)
14491484 p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe()
@@ -1444,6 +1444,41 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
1444 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)1444 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1445 }1445 }
1446 1446
1447+// TestMouseGatedWhileNoticesOverlayActive guards against a click on the
1448+// notifications modal falling through to pane hit-testing underneath it: the
1449+// modal has no mouse handler of its own, so without the gate a click would
1450+// silently retarget focus and forward SGR bytes to whichever pane sits under
1451+// the modal's coordinates.
1452+func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
1453+ p1 := portsmocks.NewMockPTY(t)
1454+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1455+ p2 := portsmocks.NewMockPTY(t)
1456+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1457+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1458+ d.procComm = nil
1459+ tb := sess.activeTab()
1460+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1461+ p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1462+ tb.mu.Lock()
1463+ tb.size = domain.Size{Cols: 41, Rows: 5}
1464+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1465+ tb.tree.Focus = "pane-1"
1466+ tb.panes["pane-2"] = p2pane
1467+ tb.mu.Unlock()
1468+
1469+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
1470+ d.enterNotices(sess, ac)
1471+ awaitFrame(t, sends, ports.MsgOutput)
1472+ require.True(t, ac.overlays.noticesActive())
1473+
1474+ // Same coordinates that TestMouseHitTestFocusesPaneAndTranslatesSGRColumns
1475+ // proves would otherwise focus pane-2 and forward the SGR report to it.
1476+ d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1477+
1478+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus, "mouse must not reach pane hit-testing while the notices overlay is open")
1479+ require.True(t, ac.overlays.noticesActive(), "mouse click must not close the notices overlay")
1480+}
1481+
1447 func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {1482 func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
1448 p1 := portsmocks.NewMockPTY(t)1483 p1 := portsmocks.NewMockPTY(t)
1449 p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe()1484 p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close