From 0007c96b9db784829010bdac788e85b0f858c220 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 4 Sep 2026 20:46:28 +1000 Subject: [PATCH] fix: prevent trace exit while scrolling --- pkg/app/model.go | 5 ++++ pkg/app/update.go | 14 ++++++++++- pkg/app/update_key_esc.go | 50 +++++++++++++++++++++++++++++++++++++++ pkg/app/update_test.go | 35 +++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/pkg/app/model.go b/pkg/app/model.go index fdebe40..40fbc21 100644 --- a/pkg/app/model.go +++ b/pkg/app/model.go @@ -71,6 +71,11 @@ type Model struct { // showHelp overlays the key map. showHelp bool + // A trace-closing Escape is briefly deferred because terminal navigation + // keys are ESC-prefixed sequences which can be split across input reads. + traceClosePending bool + traceCloseSequence uint64 + // filter narrows the list on screen. filterFocused is whether it is being // typed into; a filter stays in force after the cursor leaves it. Search // and Logs share a value, while each trace page keeps its own so a URI diff --git a/pkg/app/update.go b/pkg/app/update.go index d643d30..50ae085 100644 --- a/pkg/app/update.go +++ b/pkg/app/update.go @@ -13,6 +13,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handle key presses. case tea.KeyMsg: + // Bubble Tea can emit the ESC byte of an arrow/wheel sequence as a + // standalone key when the terminal splits the sequence across reads. + if m.cancelTraceCloseForContinuation(msg) { + return m, nil + } + // While the filter has the cursor almost every key is text, so it is // asked first and only the keys which leave it are taken away. if m.filterFocused { @@ -63,11 +69,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - if m.showHelp || m.inTrace() { + if m.showHelp { return m.updateKeyEsc() } + if m.inTrace() { + return m.deferTraceClose() + } } + case deferredTraceCloseMsg: + return m.updateDeferredTraceClose(msg) + case tea.WindowSizeMsg: return m.updateWindowSize(msg) diff --git a/pkg/app/update_key_esc.go b/pkg/app/update_key_esc.go index 8ede8ba..167c274 100644 --- a/pkg/app/update_key_esc.go +++ b/pkg/app/update_key_esc.go @@ -1,11 +1,61 @@ package app import ( + "time" + tea "github.com/charmbracelet/bubbletea" ) +const traceCloseEscapeWait = 50 * time.Millisecond + +type deferredTraceCloseMsg struct { + sequence uint64 +} + +// deferTraceClose gives the remainder of an ESC-prefixed terminal navigation +// sequence time to arrive before treating its first byte as a standalone Esc. +func (m *Model) deferTraceClose() (tea.Model, tea.Cmd) { + m.traceCloseSequence++ + m.traceClosePending = true + sequence := m.traceCloseSequence + + return m, tea.Tick(traceCloseEscapeWait, func(time.Time) tea.Msg { + return deferredTraceCloseMsg{sequence: sequence} + }) +} + +// cancelTraceCloseForContinuation consumes the suffix left behind when Bubble +// Tea receives an ANSI sequence in a separate read from its leading ESC byte. +func (m *Model) cancelTraceCloseForContinuation(msg tea.KeyMsg) bool { + if !m.traceClosePending || msg.Type != tea.KeyRunes || len(msg.Runes) == 0 { + return false + } + + if msg.Runes[0] != '[' && msg.Runes[0] != 'O' { + return false + } + + m.traceClosePending = false + m.traceCloseSequence++ + + return true +} + +// updateDeferredTraceClose closes the trace only if no ANSI continuation has +// invalidated the timer which was created for this Escape key. +func (m *Model) updateDeferredTraceClose(msg deferredTraceCloseMsg) (tea.Model, tea.Cmd) { + if !m.traceClosePending || msg.sequence != m.traceCloseSequence || !m.inTrace() { + return m, nil + } + + return m.updateKeyEsc() +} + // updateKeyEsc closes whatever is open: the help overlay first, then the trace. func (m *Model) updateKeyEsc() (tea.Model, tea.Cmd) { + m.traceClosePending = false + m.traceCloseSequence++ + if m.showHelp { m.showHelp = false diff --git a/pkg/app/update_test.go b/pkg/app/update_test.go index 62a9566..b296259 100644 --- a/pkg/app/update_test.go +++ b/pkg/app/update_test.go @@ -3,6 +3,7 @@ package app import ( "testing" + tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -159,3 +160,37 @@ func newNavTestModel() *Model { return m } + +// Arrow and wheel input is an ESC-prefixed ANSI sequence. Bubble Tea can split +// it into a standalone Esc and a rune suffix when the terminal read fragments, +// which must not close the trace. +func TestUpdate_FragmentedArrowDoesNotCloseTrace(t *testing.T) { + m := openedTrace(t) + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + require.NotNil(t, cmd) + assert.Equal(t, PageFunctions, m.PageSelected) + assert.True(t, m.traceClosePending) + sequence := m.traceCloseSequence + + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[B")}) + assert.Equal(t, PageFunctions, m.PageSelected) + assert.False(t, m.traceClosePending) + + // The already-scheduled close must also be harmless after cancellation. + m.Update(deferredTraceCloseMsg{sequence: sequence}) + assert.Equal(t, PageFunctions, m.PageSelected) + require.NotNil(t, m.Current) +} + +func TestUpdate_StandaloneEscStillReturnsToSearch(t *testing.T) { + m := openedTrace(t) + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + require.NotNil(t, cmd) + assert.Equal(t, PageFunctions, m.PageSelected) + + m.Update(cmd()) + assert.Equal(t, PageSearch, m.PageSelected) + assert.False(t, m.traceClosePending) +}