Skip to content

Commit 6e1fa00

Browse files
committed
Feat(abctl): page-up / page-down navigation in the events timeline
session.max_events is now 500, so one-row-at-a-time nav through a busy session is tedious. Bind PgUp/PgDn (and pgdn) to page the active pane by a near-full screen — events/sessions/pipeline/catalog tables move the cursor by (visible height − 1), one row of overlap for context, clamped to the row range; the detail viewport delegates to its built-in page scroll. Surface "[⇞⇟] page" in the events footer. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent 642a90b commit 6e1fa00

2 files changed

Lines changed: 81 additions & 1 deletion

File tree

authbridge/cmd/abctl/tui/events_pane_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,44 @@ import (
55
"testing"
66
"time"
77

8+
tea "github.com/charmbracelet/bubbletea"
9+
810
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
911
)
1012

13+
// TestPageActivePane_EventsTable verifies PgDn/PgUp page the events table by a
14+
// near-full screen (one row of overlap), clamped to the row range — the lever
15+
// for sessions that now hold up to session.max_events (500) rows.
16+
func TestPageActivePane_EventsTable(t *testing.T) {
17+
events := make([]pipeline.SessionEvent, 40)
18+
for i := range events {
19+
events[i] = pipeline.SessionEvent{
20+
Direction: pipeline.Outbound, Phase: pipeline.SessionRequest,
21+
Host: "h", Inference: &pipeline.InferenceExtension{Model: "m"},
22+
}
23+
}
24+
m := &model{
25+
pane: paneEvents, selectedSess: "s", bodyHeight: 12,
26+
events: map[string][]pipeline.SessionEvent{"s": events},
27+
}
28+
m.eventsTbl = newEventsTable()
29+
m.rebuildEventsTable()
30+
m.eventsTbl.SetCursor(0)
31+
32+
h := m.eventsTbl.Height()
33+
if h < 2 {
34+
t.Fatalf("table height too small to page: %d", h)
35+
}
36+
m.pageActivePane(tea.KeyMsg{Type: tea.KeyPgDown})
37+
if got := m.eventsTbl.Cursor(); got != h-1 {
38+
t.Errorf("PgDn from top: cursor=%d, want %d", got, h-1)
39+
}
40+
m.pageActivePane(tea.KeyMsg{Type: tea.KeyPgUp})
41+
if got := m.eventsTbl.Cursor(); got != 0 {
42+
t.Errorf("PgUp back to top: cursor=%d, want 0", got)
43+
}
44+
}
45+
1146
// TestShortPhase covers the rendered string for every SessionPhase.
1247
// SessionDenied renders as "req" (not "deny") because the deny
1348
// outcome is already on the row in the ACTION + STATUS columns —

authbridge/cmd/abctl/tui/keys.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"time"
66

7+
"github.com/charmbracelet/bubbles/table"
78
tea "github.com/charmbracelet/bubbletea"
89

910
"github.com/kagenti/kagenti-extensions/authbridge/cmd/abctl/apiclient"
@@ -272,6 +273,14 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
272273
m.goBottom()
273274
return nil
274275

276+
case "pgup", "pgdown", "pgdn":
277+
// Page the active pane. Sessions can hold up to session.max_events
278+
// (500) rows, so one-row-at-a-time nav isn't enough. Explicit here
279+
// (rather than relying on the table component's own binding) so the
280+
// page size carries a one-row overlap for context and the detail
281+
// viewport pages too.
282+
return m.pageActivePane(msg)
283+
275284
case "P":
276285
// Open the registered-plugin catalog. Available from any
277286
// session-view pane; in --endpoint mode the picker fields
@@ -387,6 +396,42 @@ func (m *model) goBottom() {
387396
}
388397
}
389398

399+
// pageActivePane moves the active pane by one page. Tables move the cursor by
400+
// (visible height − 1) rows — one row of overlap keeps context across the
401+
// jump — clamped to the row range by table.MoveUp/MoveDown. The detail/
402+
// plugin-detail viewport delegates to its built-in page scrolling. Picker
403+
// panes (namespaces/pods) never reach here; they return early in handleKey
404+
// and page via their own component's binding.
405+
func (m *model) pageActivePane(msg tea.KeyMsg) tea.Cmd {
406+
up := msg.String() == "pgup"
407+
page := func(t *table.Model) {
408+
n := t.Height() - 1
409+
if n < 1 {
410+
n = 1
411+
}
412+
if up {
413+
t.MoveUp(n)
414+
} else {
415+
t.MoveDown(n)
416+
}
417+
}
418+
switch m.pane {
419+
case paneEvents:
420+
page(&m.eventsTbl)
421+
case paneSessions:
422+
page(&m.sessionsTbl)
423+
case panePipeline:
424+
page(&m.pipelineTbl)
425+
case paneCatalog:
426+
page(&m.catalogTbl)
427+
case paneDetail, panePluginDetail:
428+
var cmd tea.Cmd
429+
m.detailVp, cmd = m.detailVp.Update(msg)
430+
return cmd
431+
}
432+
return nil
433+
}
434+
390435
// setFlash shows a transient message in the footer for flashDuration.
391436
func (m *model) setFlash(s string) {
392437
m.flash = s
@@ -414,7 +459,7 @@ func (m *model) helpView() string {
414459
if m.hideInactive {
415460
skipHint = "[s] show all"
416461
}
417-
base := "[↑↓] nav [↵] detail [esc] back [/] filter " + skipHint + " [p] pause [q] quit"
462+
base := "[↑↓] nav [⇞⇟] page [↵] detail [esc] back [/] filter " + skipHint + " [p] pause [q] quit"
418463
// Surface the hidden-message count so a filtered timeline doesn't
419464
// look like data loss. Only annotate when hiding is on AND at
420465
// least one message was hidden.

0 commit comments

Comments
 (0)