diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-26 23:08:14 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-26 23:08:14 +0200 |
| commit | c3106802208b18f78d4ff4b22e1d889ac19f817f (patch) | |
| tree | c01fe438cf1d0699d7b08b919c3b5494ee18a32f /internal/tui | |
| parent | dc7478d7dadf544787a9718608f11312bd2ea944 (diff) | |
tui: add paused stream column selection and split pid/tid
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/common/keys.go | 8 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 15 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 16 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 53 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 41 | ||||
| -rw-r--r-- | internal/tui/eventstream/render.go | 79 | ||||
| -rw-r--r-- | internal/tui/eventstream/render_test.go | 16 |
7 files changed, 163 insertions, 65 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index acb066b..30bc848 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -64,8 +64,8 @@ func (k KeyMap) DashboardStatusHelp() []key.Binding { k.Probes, k.Refresh, k.Quit, - helpTextBinding("left/right", "tab"), - helpTextBinding("h/l", "tab"), + helpTextBinding("left/right", "stream col"), + helpTextBinding("h/l", "stream col"), helpTextBinding("j/k", "scroll"), helpTextBinding("up/down", "scroll"), ) @@ -84,8 +84,8 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding { {k.One, k.Two, k.Three, k.Four, k.Five, k.Six}, controls, { - helpTextBinding("left/right", "tab"), - helpTextBinding("h/l", "tab"), + helpTextBinding("left/right", "stream col"), + helpTextBinding("h/l", "stream col"), helpTextBinding("j/k", "scroll"), helpTextBinding("up/down", "scroll"), }, diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 026e63e..fae6a1b 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -107,7 +107,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { prevActiveTab := m.activeTab var cmd tea.Cmd keyStr := msg.String() - handled := m.handleArrowTabKey(keyStr) || m.handleScrollKey(msg) + handled := m.handleScrollKey(msg) if handled && m.activeTab == TabStream && (keyStr == " " || keyStr == "space") && !m.streamModel.Paused() { cmd = streamTickCmd() } @@ -164,19 +164,6 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } -func (m *Model) handleArrowTabKey(keyStr string) bool { - switch keyStr { - case "right", "l": - m.activeTab = nextTab(m.activeTab) - return true - case "left", "h": - m.activeTab = prevTab(m.activeTab) - return true - default: - return false - } -} - func (m *Model) handleScrollKey(msg tea.KeyMsg) bool { keyStr := msg.String() switch m.activeTab { diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index a0e0539..37dbe28 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -59,31 +59,31 @@ func TestKeySwitchingChangesActiveTab(t *testing.T) { } } -func TestArrowAndViKeysCycleTabs(t *testing.T) { +func TestArrowAndViKeysDoNotCycleTabs(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) model := next.(Model) - if model.activeTab != TabSyscalls { - t.Fatalf("expected right arrow to move to syscalls, got %v", model.activeTab) + if model.activeTab != TabOverview { + t.Fatalf("expected right arrow not to change tabs, got %v", model.activeTab) } next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) model = next.(Model) - if model.activeTab != TabFiles { - t.Fatalf("expected l to move to files, got %v", model.activeTab) + if model.activeTab != TabOverview { + t.Fatalf("expected l not to change tabs, got %v", model.activeTab) } next, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) model = next.(Model) - if model.activeTab != TabSyscalls { - t.Fatalf("expected left arrow to move back to syscalls, got %v", model.activeTab) + if model.activeTab != TabOverview { + t.Fatalf("expected left arrow not to change tabs, got %v", model.activeTab) } next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) model = next.(Model) if model.activeTab != TabOverview { - t.Fatalf("expected h to move back to overview, got %v", model.activeTab) + t.Fatalf("expected h not to change tabs, got %v", model.activeTab) } } diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index 42018d5..d757209 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -7,6 +7,8 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +const streamColumnCount = 10 + type Model struct { source *RingBuffer @@ -24,6 +26,7 @@ type Model struct { scrollOffset int autoScroll bool selectedIdx int + selectedCol int fdTraceView fdTraceViewState width int @@ -44,6 +47,7 @@ func NewModel(source *RingBuffer) Model { filterModal: NewFilterModal(), autoScroll: true, selectedIdx: -1, + selectedCol: 0, } } @@ -98,6 +102,10 @@ func (m *Model) HandleKey(keyStr string) bool { case "k", "up": m.scrollFDTraceByLines(-1) return true + case "left", "h": + return true + case "right", "l": + return true case "pgdown", "pgdn", "pagedown": m.scrollFDTraceByLines(m.pageStep()) return true @@ -135,6 +143,7 @@ func (m *Model) HandleKey(keyStr string) bool { m.Refresh() } else { m.ensureSelection() + m.ensureSelectedCol() m.centerSelection() } return true @@ -177,6 +186,18 @@ func (m *Model) HandleKey(keyStr string) bool { m.scrollByLines(-1) } return true + case "left", "h": + if m.paused { + m.moveSelectedColBy(-1) + return true + } + return false + case "right", "l": + if m.paused { + m.moveSelectedColBy(1) + return true + } + return false case "pgdown", "pgdn", "pagedown": if m.paused { m.moveSelectionBy(m.pageStep()) @@ -200,6 +221,10 @@ func (m *Model) HandleKey(keyStr string) bool { // then falls back to string matching for rune-driven shortcuts. func (m *Model) HandleTeaKey(msg tea.KeyMsg) bool { switch msg.Type { + case tea.KeyLeft: + return m.HandleKey("left") + case tea.KeyRight: + return m.HandleKey("right") case tea.KeyUp: return m.HandleKey("up") case tea.KeyDown: @@ -253,10 +278,14 @@ func (m *Model) View(width, height int) string { bufferLen = m.source.Len() } - base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible, selectedVisibleIdx) + selectedCol := -1 + if m.paused && selectedVisibleIdx >= 0 { + selectedCol = m.selectedCol + } + base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible, selectedVisibleIdx, selectedCol) status := fmt.Sprintf("Row %d/%d | space:pause f:filter G:tail g:top c:clear j/k:scroll", rowNumber(start, len(m.filtered)), len(m.filtered)) if m.paused && m.selectedIdx >= 0 { - status = fmt.Sprintf("Row %d/%d | Sel %d/%d | enter:fd-trace space:pause f:filter G:tail g:top c:clear j/k:select", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered)) + status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | enter:fd-trace space:pause f:filter G:tail g:top c:clear j/k:row h/l:col", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount) } out := base + "\n" + status @@ -309,6 +338,7 @@ func (m *Model) applyFilter() { m.clampSelection() if m.paused { m.ensureSelection() + m.ensureSelectedCol() m.centerSelection() } } @@ -435,6 +465,7 @@ func (m *Model) moveSelectionBy(delta int) { return } m.ensureSelection() + m.ensureSelectedCol() m.moveSelectionTo(m.selectedIdx + delta) } @@ -444,6 +475,7 @@ func (m *Model) moveSelectionTo(idx int) { return } m.selectedIdx = clamp(idx, 0, len(m.filtered)-1) + m.ensureSelectedCol() m.centerSelection() } @@ -469,6 +501,23 @@ func (m *Model) ensureSelection() { m.selectedIdx = clamp(m.scrollOffset+mid, 0, len(m.filtered)-1) } +func (m *Model) ensureSelectedCol() { + if m.selectedCol < 0 { + m.selectedCol = 0 + } + if m.selectedCol >= streamColumnCount { + m.selectedCol = streamColumnCount - 1 + } +} + +func (m *Model) moveSelectedColBy(delta int) { + if delta == 0 { + return + } + m.ensureSelectedCol() + m.selectedCol = clamp(m.selectedCol+delta, 0, streamColumnCount-1) +} + func (m *Model) clampSelection() { if len(m.filtered) == 0 { m.selectedIdx = -1 diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go index 3dac038..e58144a 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -373,6 +373,47 @@ func TestPausedSelectionMovesAndRecentersWithJKAndArrows(t *testing.T) { } } +func TestPausedSelectionMovesAcrossColumnsWithLeftRightAndHL(t *testing.T) { + rb := NewRingBuffer() + m := NewModel(rb) + m.height = 20 + pushEvents(rb, 100) + m.Refresh() + + if !m.HandleKey("space") { + t.Fatalf("space should toggle pause") + } + startCol := m.selectedCol + startRow := m.selectedIdx + + if !m.HandleKey("right") { + t.Fatalf("right should be handled while paused") + } + if m.selectedCol != startCol+1 { + t.Fatalf("expected selected col +1 after right, got %d->%d", startCol, m.selectedCol) + } + if m.selectedIdx != startRow { + t.Fatalf("expected selected row unchanged after right, got %d->%d", startRow, m.selectedIdx) + } + + if !m.HandleKey("l") { + t.Fatalf("l should be handled while paused") + } + if m.selectedCol != startCol+2 { + t.Fatalf("expected selected col +2 after l, got %d", m.selectedCol) + } + + if !m.HandleKey("left") { + t.Fatalf("left should be handled while paused") + } + if !m.HandleKey("h") { + t.Fatalf("h should be handled while paused") + } + if m.selectedCol != startCol { + t.Fatalf("expected selected col back to start, got %d", m.selectedCol) + } +} + func TestPausedEnterOpensFDTraceViewScopedByPIDAndFD(t *testing.T) { rb := NewRingBuffer() rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, FD: 3, Syscall: "read", FileName: "/a"}) diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go index e9d44f1..1f539c6 100644 --- a/internal/tui/eventstream/render.go +++ b/internal/tui/eventstream/render.go @@ -13,7 +13,8 @@ type columnLayout struct { gap int latency int comm int - pidTid int + pid int + tid int syscall int fd int ret int @@ -26,7 +27,12 @@ var selectedRowStyle = lipgloss.NewStyle(). Foreground(common.ColorBackground). Background(common.ColorPrimary) -func RenderStreamTable(width int, paused bool, totalCount, filteredCount, bufferLen, bufferCap int, filter Filter, events []StreamEvent, selectedVisibleIdx int) string { +var selectedCellStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(common.ColorBackground). + Background(common.ColorAccent) + +func RenderStreamTable(width int, paused bool, totalCount, filteredCount, bufferLen, bufferCap int, filter Filter, events []StreamEvent, selectedVisibleIdx int, selectedCol int) string { if width <= 0 { width = 100 } @@ -37,7 +43,11 @@ func RenderStreamTable(width int, paused bool, totalCount, filteredCount, buffer lines = append(lines, renderFilterLine(filter)) lines = append(lines, renderColumnHeader(contentWidth)) for i, ev := range events { - lines = append(lines, renderEventRow(ev, contentWidth, i == selectedVisibleIdx)) + col := -1 + if i == selectedVisibleIdx { + col = selectedCol + } + lines = append(lines, renderEventRow(ev, contentWidth, i == selectedVisibleIdx, col)) } return common.PanelStyle.Width(contentWidth).Render(strings.Join(lines, "\n")) @@ -54,7 +64,7 @@ func RenderFDTraceTable(width int, pid uint32, fd int32, totalCount int, events lines = append(lines, fmt.Sprintf("PID:%d FD:%d matched:%d", pid, fd, totalCount)) lines = append(lines, renderColumnHeader(contentWidth)) for _, ev := range events { - lines = append(lines, renderEventRow(ev, contentWidth, false)) + lines = append(lines, renderEventRow(ev, contentWidth, false, -1)) } return common.PanelStyle.Width(contentWidth).Render(strings.Join(lines, "\n")) @@ -82,11 +92,12 @@ func renderFilterLine(filter Filter) string { func renderColumnHeader(width int) string { cols := computeColumnLayout(width) - header := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %s", + header := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %s", cols.gap, "Gap", cols.latency, "Latency", cols.comm, "Comm", - cols.pidTid, "PID.TID", + cols.pid, "PID", + cols.tid, "TID", cols.syscall, "Syscall", cols.fd, "FD", cols.ret, "Ret", @@ -96,31 +107,38 @@ func renderColumnHeader(width int) string { return common.HelpBarStyle.Render(header) } -func renderEventRow(ev StreamEvent, width int, selected bool) string { +func renderEventRow(ev StreamEvent, width int, selected bool, selectedCol int) string { cols := computeColumnLayout(width) - pidTid := fmt.Sprintf("%d.%d", ev.PID, ev.TID) fd := "-" if ev.FD >= 0 { fd = strconv.FormatInt(int64(ev.FD), 10) } - row := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %s", - cols.gap, fitCell(formatDurationNs(ev.GapNs), cols.gap), - cols.latency, fitCell(formatDurationNs(ev.DurationNs), cols.latency), - cols.comm, fitCell(ev.Comm, cols.comm), - cols.pidTid, fitCell(pidTid, cols.pidTid), - cols.syscall, fitCell(ev.Syscall, cols.syscall), - cols.fd, fitCell(fd, cols.fd), - cols.ret, fitCell(strconv.FormatInt(ev.RetVal, 10), cols.ret), - cols.bytes, fitCell(strconv.FormatUint(ev.Bytes, 10), cols.bytes), + cells := []string{ + fmt.Sprintf("%-*s", cols.gap, fitCell(formatDurationNs(ev.GapNs), cols.gap)), + fmt.Sprintf("%-*s", cols.latency, fitCell(formatDurationNs(ev.DurationNs), cols.latency)), + fmt.Sprintf("%-*s", cols.comm, fitCell(ev.Comm, cols.comm)), + fmt.Sprintf("%-*s", cols.pid, fitCell(strconv.FormatUint(uint64(ev.PID), 10), cols.pid)), + fmt.Sprintf("%-*s", cols.tid, fitCell(strconv.FormatUint(uint64(ev.TID), 10), cols.tid)), + fmt.Sprintf("%-*s", cols.syscall, fitCell(ev.Syscall, cols.syscall)), + fmt.Sprintf("%-*s", cols.fd, fitCell(fd, cols.fd)), + fmt.Sprintf("%-*s", cols.ret, fitCell(strconv.FormatInt(ev.RetVal, 10), cols.ret)), + fmt.Sprintf("%-*s", cols.bytes, fitCell(strconv.FormatUint(ev.Bytes, 10), cols.bytes)), fitCell(ev.FileName, cols.file), - ) + } if selected { - return selectedRowStyle.Render(row) + for i := range cells { + if i == selectedCol { + cells[i] = selectedCellStyle.Render(cells[i]) + } else { + cells[i] = selectedRowStyle.Render(cells[i]) + } + } + return strings.Join(cells, " ") } if ev.IsError { - return common.ErrorStyle.Render(row) + return common.ErrorStyle.Render(strings.Join(cells, " ")) } - return row + return strings.Join(cells, " ") } func computeColumnLayout(width int) columnLayout { @@ -132,38 +150,41 @@ func computeColumnLayout(width int) columnLayout { gap := 7 latency := 8 comm := 10 - pidTid := 10 + pid := 7 + tid := 7 syscall := 9 fd := 4 ret := 5 bytes := 8 - fixed := gap + latency + comm + pidTid + syscall + fd + ret + bytes + 8 + fixed := gap + latency + comm + pid + tid + syscall + fd + ret + bytes + 9 file := width - fixed if file >= 28 { // On wider terminals, give a little more room back to descriptive columns. if width >= 140 { comm = 12 syscall = 11 - pidTid = 11 - fixed = gap + latency + comm + pidTid + syscall + fd + ret + bytes + 8 + pid = 8 + tid = 8 + fixed = gap + latency + comm + pid + tid + syscall + fd + ret + bytes + 9 file = width - fixed } - return columnLayout{gap: gap, latency: latency, comm: comm, pidTid: pidTid, syscall: syscall, fd: fd, ret: ret, bytes: bytes, file: file} + return columnLayout{gap: gap, latency: latency, comm: comm, pid: pid, tid: tid, syscall: syscall, fd: fd, ret: ret, bytes: bytes, file: file} } // Very narrow widths: compress further but keep file column readable. comm = 8 - pidTid = 9 + pid = 6 + tid = 6 syscall = 8 fd = 3 ret = 4 bytes = 7 - fixed = gap + latency + comm + pidTid + syscall + fd + ret + bytes + 8 + fixed = gap + latency + comm + pid + tid + syscall + fd + ret + bytes + 9 file = width - fixed if file < 12 { file = 12 } - return columnLayout{gap: gap, latency: latency, comm: comm, pidTid: pidTid, syscall: syscall, fd: fd, ret: ret, bytes: bytes, file: file} + return columnLayout{gap: gap, latency: latency, comm: comm, pid: pid, tid: tid, syscall: syscall, fd: fd, ret: ret, bytes: bytes, file: file} } func formatDurationNs(v uint64) string { diff --git a/internal/tui/eventstream/render_test.go b/internal/tui/eventstream/render_test.go index 33e5b38..b020edf 100644 --- a/internal/tui/eventstream/render_test.go +++ b/internal/tui/eventstream/render_test.go @@ -10,7 +10,7 @@ import ( func TestRenderStatusAndFilterLines(t *testing.T) { events := []StreamEvent{{Syscall: "read", Comm: "nginx", PID: 1, TID: 2, DurationNs: 1200, GapNs: 300, Bytes: 64, FileName: "/tmp/a", RetVal: 64}} f := Filter{Syscall: &StringFilter{Pattern: "read"}, PID: &NumericFilter{Op: OpEq, Value: 1}} - out := RenderStreamTable(120, false, 100, 1, 100, 10000, f, events, -1) + out := RenderStreamTable(120, false, 100, 1, 100, 10000, f, events, -1, -1) for _, want := range []string{"LIVE", "total:100", "filtered:1", "buffer:100/10000", "Filter:", "syscall~read", "pid=1"} { if !strings.Contains(out, want) { @@ -21,7 +21,7 @@ func TestRenderStatusAndFilterLines(t *testing.T) { func TestRenderPausedAndErrorRow(t *testing.T) { events := []StreamEvent{{Syscall: "write", Comm: "worker", PID: 1, TID: 2, DurationNs: 1000000, GapNs: 5000, Bytes: 32, FileName: "/tmp/b", RetVal: -1, IsError: true}} - out := RenderStreamTable(120, true, 10, 1, 10, 10000, Filter{}, events, -1) + out := RenderStreamTable(120, true, 10, 1, 10, 10000, Filter{}, events, -1, -1) if !strings.Contains(out, "PAUSED") { t.Fatalf("expected PAUSED indicator\n%s", out) @@ -36,7 +36,7 @@ func TestRenderPausedAndErrorRow(t *testing.T) { func TestRenderShowsFDWhenPresent(t *testing.T) { events := []StreamEvent{{Syscall: "read", Comm: "worker", PID: 1, TID: 2, FD: 9, DurationNs: 10, GapNs: 1, Bytes: 8, FileName: "/tmp/b", RetVal: 8}} - out := RenderStreamTable(120, false, 1, 1, 1, 10000, Filter{}, events, -1) + out := RenderStreamTable(120, false, 1, 1, 1, 10000, Filter{}, events, -1, -1) if !strings.Contains(out, "FD") || !strings.Contains(out, " 9 ") { t.Fatalf("expected FD column/value in output\n%s", out) } @@ -54,9 +54,9 @@ func TestRenderHeaderAndTruncate(t *testing.T) { FileName: "/very/long/path/that/should/be/truncated/for/narrow/views/file.log", RetVal: 1, }} - out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, events, -1) + out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, events, -1, -1) - for _, col := range []string{"Gap", "Latency", "Comm", "PID.TID", "Syscall", "FD", "Ret", "Bytes", "File"} { + for _, col := range []string{"Gap", "Latency", "Comm", "PID", "TID", "Syscall", "FD", "Ret", "Bytes", "File"} { if !strings.Contains(out, col) { t.Fatalf("missing column %q\n%s", col, out) } @@ -95,7 +95,7 @@ func TestRenderEventRowIsSingleLineWithControlCharsAndLongValues(t *testing.T) { RetVal: -9223372036854775808, } - row := renderEventRow(ev, 80, false) + row := renderEventRow(ev, 80, false, -1) if strings.Contains(row, "\n") || strings.Contains(row, "\r") || strings.Contains(row, "\t") { t.Fatalf("expected a sanitized single-line row, got %q", row) } @@ -106,7 +106,7 @@ func TestRenderEventRowIsSingleLineWithControlCharsAndLongValues(t *testing.T) { func TestComputeColumnLayoutGivesFileMoreSpace(t *testing.T) { cols := computeColumnLayout(120) - if cols.file < 50 { + if cols.file < 44 { t.Fatalf("expected file column to get most width, got %d", cols.file) } } @@ -124,7 +124,7 @@ func TestRenderStreamTableFitsRequestedWidth(t *testing.T) { FileName: "/very/long/path/that/should/be/truncated/for/narrow/views/file.log", RetVal: 1, }, - }, -1) + }, -1, -1) for _, line := range strings.Split(out, "\n") { if lipgloss.Width(line) > 80 { |
