From dd1ec8a56bb546910c7631fb5c73c7b23f36cd4f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 12:13:31 +0100 Subject: [PATCH 1/4] Remove a stray comment line --- pkg/gocui/gui.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index afc71a2ed..786a0338a 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1752,8 +1752,6 @@ func (g *Gui) draw(v *View) { func (g *Gui) onKey(ev *GocuiEvent) error { switch ev.Type { case eventKey: - - // newlines. I actually don't quite understand why, because from reading // When pasting text in Ghostty, it sends us '\r' (which is delivered as // ctrl-j by tcell) instead of '\n' for newlines. I actually don't quite // understand why, because from reading Ghostty's source code (e.g. From 2a39f2b56414797665307d2c4e0c7688cf81e58d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 12:14:22 +0100 Subject: [PATCH 2/4] Rename bufferedKeyEvents to bufferedInputEvents The next commit buffers paste events in it too. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/block_events_test.go | 4 ++-- pkg/gocui/gui.go | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/gocui/block_events_test.go b/pkg/gocui/block_events_test.go index e95b834ff..adde75829 100644 --- a/pkg/gocui/block_events_test.go +++ b/pkg/gocui/block_events_test.go @@ -86,7 +86,7 @@ func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) { runQueuedWork(t, g) assert.Len(t, *fired, 3, "both buffered keys should replay on unblock") - assert.Empty(t, g.bufferedKeyEvents) + assert.Empty(t, g.bufferedInputEvents) } func TestBlockingEvents_KeysArrivingBeforeTheReplayGoBehindIt(t *testing.T) { @@ -136,7 +136,7 @@ func TestBlockingEvents_MouseClicksDroppedNotBuffered(t *testing.T) { g.BeginBlockingEvents() click := GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)} assert.NoError(t, g.handleEvent(&click)) - assert.Empty(t, g.bufferedKeyEvents, "mouse clicks must be dropped, not buffered") + assert.Empty(t, g.bufferedInputEvents, "mouse clicks must be dropped, not buffered") g.EndBlockingEvents() runQueuedWork(t, g) } diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 786a0338a..445feab80 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -242,15 +242,15 @@ type Gui struct { colorSchemeHandler func(DetectedColorScheme) error // blockInputCount, when greater than zero, withholds keyboard input from - // the handlers: key events are buffered into bufferedKeyEvents and replayed + // the handlers: key events are buffered into bufferedInputEvents and replayed // once the count drops back to zero, while mouse clicks and hover are // dropped outright. It's a counter so blocking can nest. replayPending says // that the replay is queued but hasn't run yet, and input is withheld until // it has. All three fields are only touched on the UI thread. See // BeginBlockingEvents. - blockInputCount int - bufferedKeyEvents []GocuiEvent - replayPending bool + blockInputCount int + bufferedInputEvents []GocuiEvent + replayPending bool } type NewGuiOpts struct { @@ -963,18 +963,18 @@ func (g *Gui) EndBlockingEvents() { // before the replay gets its turn would otherwise be handled ahead of the keys // buffered before it. g.replayPending = true - g.Update(func(*Gui) error { return g.replayBufferedKeys() }) + g.Update(func(*Gui) error { return g.replayBufferedInput() }) } -// replayBufferedKeys dispatches the keys withheld while input was blocked, and +// replayBufferedInput dispatches the keys withheld while input was blocked, and // lets input through again. One of their handlers may block input afresh, and // then the keys after it are withheld in their turn, to be replayed when that // block ends. -func (g *Gui) replayBufferedKeys() error { +func (g *Gui) replayBufferedInput() error { g.replayPending = false - buffered := g.bufferedKeyEvents - g.bufferedKeyEvents = nil + buffered := g.bufferedInputEvents + g.bufferedInputEvents = nil for i := range buffered { if err := g.handleEvent(&buffered[i]); err != nil { return err @@ -1253,7 +1253,7 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error { if g.withholdingInput() && eventWithheldWhileBlocking(ev) { if ev.Type == eventKey { // Buffer keys so they replay against fresh state on unblock. - g.bufferedKeyEvents = append(g.bufferedKeyEvents, *ev) + g.bufferedInputEvents = append(g.bufferedInputEvents, *ev) } // Mouse clicks and hover fall through to here without being buffered: // replaying them once the operation has changed the layout underneath From 1be1a5cbfd3a7455562228b0b4c00459f893d97c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 12:14:44 +0100 Subject: [PATCH 3/4] Add a test for pasting while input is blocked While a long-running operation blocks input, lazygit buffers key events and replays them when the operation ends. Paste events aren't buffered, so they take effect right away. If the user pastes text during the block, the paste has ended by the time its keys are replayed, and lazygit treats them as typed keys. In a list panel, every character of the text then runs the command bound to it. Without the block, lazygit ignores keys pasted into a list panel. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/block_events_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pkg/gocui/block_events_test.go b/pkg/gocui/block_events_test.go index adde75829..a2643f96b 100644 --- a/pkg/gocui/block_events_test.go +++ b/pkg/gocui/block_events_test.go @@ -18,6 +18,9 @@ func TestEventWithheldWhileBlocking(t *testing.T) { {"mouse move", GocuiEvent{Type: eventMouseMove}, true}, {"resize", GocuiEvent{Type: eventResize}, false}, {"focus", GocuiEvent{Type: eventFocus}, false}, + /* EXPECTED: + {"paste", GocuiEvent{Type: eventPaste}, true}, + ACTUAL: */ {"paste", GocuiEvent{Type: eventPaste}, false}, {"error", GocuiEvent{Type: eventError}, false}, } @@ -111,6 +114,32 @@ func TestBlockingEvents_KeysArrivingBeforeTheReplayGoBehindIt(t *testing.T) { assert.Equal(t, []int{1, 0}, *fired, "the keys should arrive in the order they were pressed") } +func TestBlockingEvents_PastedKeysReplayedAsPasted(t *testing.T) { + g := newTestGui(t) + keyEvent, fired := setupKeyRecorder(t, g) + pasteStart := GocuiEvent{Type: eventPaste, Start: true} + pasteEnd := GocuiEvent{Type: eventPaste, Start: false} + + // Pasting into a view that isn't editable doesn't run its keybindings. + assert.NoError(t, g.handleEvent(&pasteStart)) + assert.NoError(t, g.handleEvent(&keyEvent)) + assert.NoError(t, g.handleEvent(&pasteEnd)) + assert.Empty(t, *fired) + + // The same must hold for a paste that arrives while input is blocked. + g.BeginBlockingEvents() + assert.NoError(t, g.handleEvent(&pasteStart)) + assert.NoError(t, g.handleEvent(&keyEvent)) + assert.NoError(t, g.handleEvent(&pasteEnd)) + g.EndBlockingEvents() + runQueuedWork(t, g) + /* EXPECTED: + assert.Empty(t, *fired) + ACTUAL: */ + assert.Len(t, *fired, 1) + assert.False(t, g.IsPasting) +} + func TestBlockingEvents_NestsWithCounter(t *testing.T) { g := newTestGui(t) keyEvent, fired := setupKeyRecorder(t, g) From 044a827a3e9dd6ce33c7fa4c00208a369ec29d42 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 12:16:05 +0100 Subject: [PATCH 4/4] Withhold paste events while input is blocked Buffer the start and end of a paste along with the keys, and replay them in the same order. This way lazygit still recognizes the replayed keys as pasted. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/block_events_test.go | 6 ------ pkg/gocui/gui.go | 33 ++++++++++++++++++--------------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/pkg/gocui/block_events_test.go b/pkg/gocui/block_events_test.go index a2643f96b..15af1452a 100644 --- a/pkg/gocui/block_events_test.go +++ b/pkg/gocui/block_events_test.go @@ -18,10 +18,7 @@ func TestEventWithheldWhileBlocking(t *testing.T) { {"mouse move", GocuiEvent{Type: eventMouseMove}, true}, {"resize", GocuiEvent{Type: eventResize}, false}, {"focus", GocuiEvent{Type: eventFocus}, false}, - /* EXPECTED: {"paste", GocuiEvent{Type: eventPaste}, true}, - ACTUAL: */ - {"paste", GocuiEvent{Type: eventPaste}, false}, {"error", GocuiEvent{Type: eventError}, false}, } @@ -133,10 +130,7 @@ func TestBlockingEvents_PastedKeysReplayedAsPasted(t *testing.T) { assert.NoError(t, g.handleEvent(&pasteEnd)) g.EndBlockingEvents() runQueuedWork(t, g) - /* EXPECTED: assert.Empty(t, *fired) - ACTUAL: */ - assert.Len(t, *fired, 1) assert.False(t, g.IsPasting) } diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 445feab80..8b6179a2d 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -242,12 +242,12 @@ type Gui struct { colorSchemeHandler func(DetectedColorScheme) error // blockInputCount, when greater than zero, withholds keyboard input from - // the handlers: key events are buffered into bufferedInputEvents and replayed - // once the count drops back to zero, while mouse clicks and hover are - // dropped outright. It's a counter so blocking can nest. replayPending says - // that the replay is queued but hasn't run yet, and input is withheld until - // it has. All three fields are only touched on the UI thread. See - // BeginBlockingEvents. + // the handlers: key and paste events are buffered into bufferedInputEvents + // and replayed once the count drops back to zero, while mouse clicks and + // hover are dropped outright. It's a counter so blocking can nest. + // replayPending says that the replay is queued but hasn't run yet, and + // input is withheld until it has. All three fields are only touched on the + // UI thread. See BeginBlockingEvents. blockInputCount int bufferedInputEvents []GocuiEvent replayPending bool @@ -929,9 +929,10 @@ func (g *Gui) IsUIThread() bool { // BeginBlockingEvents starts withholding keyboard input from the handlers, so a // long-running operation can't be disrupted by keys the user presses while it // runs. Keys are buffered and replayed once EndBlockingEvents balances this -// call; mouse clicks and hover are dropped for the duration. Scrolling, -// resizing, focus changes and all rendering keep working throughout. It's a -// counter, so blocking nests; every call must be paired with EndBlockingEvents. +// call, together with the start and end of a paste around them; mouse clicks +// and hover are dropped for the duration. Scrolling, resizing, focus changes +// and all rendering keep working throughout. It's a counter, so blocking nests; +// every call must be paired with EndBlockingEvents. // // Must be called on the UI thread. Callers arrange this by beginning the block // synchronously from the keybinding handler, before dispatching the operation @@ -1251,8 +1252,10 @@ func (g *Gui) processRemainingEvents() (bool, error) { // etc.) func (g *Gui) handleEvent(ev *GocuiEvent) error { if g.withholdingInput() && eventWithheldWhileBlocking(ev) { - if ev.Type == eventKey { - // Buffer keys so they replay against fresh state on unblock. + if ev.Type == eventKey || ev.Type == eventPaste { + // Buffer keys so they replay against fresh state on unblock. Buffer + // the start and end of a paste with them, so that pasted keys are + // still handled as pasted when they replay. g.bufferedInputEvents = append(g.bufferedInputEvents, *ev) } // Mouse clicks and hover fall through to here without being buffered: @@ -1287,13 +1290,13 @@ func (g *Gui) withholdingInput() bool { } // eventWithheldWhileBlocking reports whether an event must not reach the -// handlers while input is blocked (see BeginBlockingEvents). Key events are -// withheld (buffered for replay); mouse clicks and hover are withheld (dropped). -// Everything else — mouse scrolling, resize, focus, paste, errors — flows +// handlers while input is blocked (see BeginBlockingEvents). Key and paste +// events are withheld (buffered for replay); mouse clicks and hover are withheld +// (dropped). Everything else — mouse scrolling, resize, focus, errors — flows // through as usual. func eventWithheldWhileBlocking(ev *GocuiEvent) bool { switch ev.Type { - case eventKey: + case eventKey, eventPaste: return true case eventMouse: return !IsMouseScrollKey(ev.Key.KeyName())