diff --git a/internal/tui/model.go b/internal/tui/model.go index 55e5574..01622a3 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -27,16 +27,31 @@ var ( Background(lipgloss.Color("#333333")). Bold(true). Padding(0, 1) + + cursorStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#3C3C5C")) + + detailBorderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7D56F4")) + + detailKeyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#117")). + Bold(true) + + detailValStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#252")) ) // LogMsg carries a new parsed and rendered log line into the TUI. type LogMsg struct { Rendered string + Entry parser.LogEntry } // LogBatchMsg carries multiple rendered log lines at once. type LogBatchMsg struct { - Lines []string + Lines []string + Entries []parser.LogEntry } // ErrMsg carries a source error into the TUI. @@ -51,14 +66,22 @@ type Model struct { ready bool // Log buffer — stores rendered strings for display. - lines []string + lines []string + entries []parser.LogEntry // parallel to lines; stores parsed entries // Virtual scrolling state. offset int // index of the first visible line autoScroll bool // stick to bottom when new lines arrive + // Cursor and detail pane. + cursor int // index of the highlighted line + showDetail bool // whether the detail pane is visible + // Source info for status bar. sourceName string + + // Filter status for status bar. + filterText string } // NewModel creates a new LogPilot TUI model with no sources. @@ -87,6 +110,31 @@ func (m Model) viewHeight() int { return h } +// detailPaneHeight returns the height of the detail pane when visible. +func (m Model) detailPaneHeight() int { + h := m.viewHeight() / 3 + if h < 5 { + h = 5 + } + if h > 15 { + h = 15 + } + return h +} + +// logPaneHeight returns the log viewport height when detail pane is visible. +func (m Model) logPaneHeight() int { + if !m.showDetail { + return m.viewHeight() + } + // detail pane takes detailPaneHeight + 1 (border line) + h := m.viewHeight() - m.detailPaneHeight() - 1 + if h < 3 { + return 3 + } + return h +} + // maxOffset returns the maximum valid scroll offset. func (m Model) maxOffset() int { max := len(m.lines) - m.viewHeight() @@ -96,6 +144,35 @@ func (m Model) maxOffset() int { return max } +// clampCursor ensures cursor is within valid bounds. +func (m *Model) clampCursor() { + if m.cursor < 0 { + m.cursor = 0 + } + if max := len(m.lines) - 1; m.cursor > max { + if max < 0 { + m.cursor = 0 + } else { + m.cursor = max + } + } +} + +// scrollToCursor adjusts offset so the cursor is visible. +func (m *Model) scrollToCursor() { + vh := m.viewHeight() + if m.showDetail { + vh = m.logPaneHeight() + } + if m.cursor < m.offset { + m.offset = m.cursor + } + if m.cursor >= m.offset+vh { + m.offset = m.cursor - vh + 1 + } + m.clampOffset() +} + // clampOffset ensures offset is within valid bounds. func (m *Model) clampOffset() { if m.offset < 0 { @@ -123,25 +200,42 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": return m, tea.Quit + case "enter": + if len(m.lines) > 0 { + m.showDetail = !m.showDetail + } + case "esc": + if m.showDetail { + m.showDetail = false + } case "j", "down": m.autoScroll = false - m.offset++ - m.clampOffset() + m.cursor++ + m.clampCursor() + m.scrollToCursor() if m.isAtBottom() { m.autoScroll = true } case "k", "up": m.autoScroll = false - m.offset-- - m.clampOffset() + m.cursor-- + m.clampCursor() + m.scrollToCursor() case "g", "home": m.autoScroll = false + m.cursor = 0 m.offset = 0 case "G", "end": + m.cursor = len(m.lines) - 1 + if m.cursor < 0 { + m.cursor = 0 + } m.offset = m.maxOffset() m.autoScroll = true case "pgdown", "f", "ctrl+f": m.autoScroll = false + m.cursor += m.viewHeight() + m.clampCursor() m.offset += m.viewHeight() m.clampOffset() if m.isAtBottom() { @@ -149,10 +243,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "pgup", "b", "ctrl+b": m.autoScroll = false + m.cursor -= m.viewHeight() + m.clampCursor() m.offset -= m.viewHeight() m.clampOffset() case "d", "ctrl+d": m.autoScroll = false + m.cursor += m.viewHeight() / 2 + m.clampCursor() m.offset += m.viewHeight() / 2 m.clampOffset() if m.isAtBottom() { @@ -160,6 +258,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "u", "ctrl+u": m.autoScroll = false + m.cursor -= m.viewHeight() / 2 + m.clampCursor() m.offset -= m.viewHeight() / 2 m.clampOffset() } @@ -175,14 +275,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case LogMsg: m.lines = append(m.lines, msg.Rendered) + m.entries = append(m.entries, msg.Entry) if m.autoScroll { m.offset = m.maxOffset() + m.cursor = len(m.lines) - 1 + if m.cursor < 0 { + m.cursor = 0 + } } case LogBatchMsg: m.lines = append(m.lines, msg.Lines...) + m.entries = append(m.entries, msg.Entries...) if m.autoScroll { m.offset = m.maxOffset() + m.cursor = len(m.lines) - 1 + if m.cursor < 0 { + m.cursor = 0 + } } case ErrMsg: @@ -209,7 +319,7 @@ func (m Model) View() string { b.WriteByte('\n') // Log viewport — virtual scrolling: only render visible slice. - vh := m.viewHeight() + vh := m.logPaneHeight() if len(m.lines) == 0 { // Empty state. for i := 0; i < vh; i++ { @@ -229,10 +339,14 @@ func (m Model) View() string { if start < 0 { start = 0 } - // Render visible lines. + // Render visible lines with cursor highlight. rendered := 0 for i := start; i < end; i++ { - b.WriteString(m.lines[i]) + line := m.lines[i] + if i == m.cursor { + line = cursorStyle.Render(line) + } + b.WriteString(line) b.WriteByte('\n') rendered++ } @@ -242,6 +356,11 @@ func (m Model) View() string { } } + // Detail pane. + if m.showDetail && len(m.entries) > 0 && m.cursor < len(m.entries) { + b.WriteString(m.renderDetailPane()) + } + // Status bar. total := len(m.lines) scrollInfo := "bottom" @@ -262,11 +381,17 @@ func (m Model) View() string { right := statusKeyStyle.Render("Pos:") + statusBarStyle.Render(fmt.Sprintf(" %s ", scrollInfo)) srcInfo := statusKeyStyle.Render("Src:") + statusBarStyle.Render(fmt.Sprintf(" %s ", src)) - gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) - lipgloss.Width(srcInfo) + // Filter status. + filterInfo := "" + if m.filterText != "" { + filterInfo = statusKeyStyle.Render("Filter:") + statusBarStyle.Render(fmt.Sprintf(" %s ", m.filterText)) + } + + gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) - lipgloss.Width(srcInfo) - lipgloss.Width(filterInfo) if gap < 0 { gap = 0 } - statusLine := left + srcInfo + strings.Repeat(" ", gap) + right + statusLine := left + srcInfo + filterInfo + strings.Repeat(" ", gap) + right // Fill background. statusLine = statusBarStyle.Render(statusLine) b.WriteString(statusLine) @@ -274,6 +399,89 @@ func (m Model) View() string { return b.String() } +// renderDetailPane renders the detail pane for the selected log entry. +func (m Model) renderDetailPane() string { + var b strings.Builder + + // Separator line. + sep := detailBorderStyle.Render(strings.Repeat("─", m.width)) + b.WriteString(sep) + b.WriteByte('\n') + + entry := m.entries[m.cursor] + dh := m.detailPaneHeight() + rendered := 0 + + // Header. + header := detailBorderStyle.Render("▼ Detail") + b.WriteString(header) + b.WriteByte('\n') + rendered++ + + // Timestamp. + if !entry.Timestamp.IsZero() && rendered < dh { + b.WriteString(detailKeyStyle.Render(" timestamp") + " " + detailValStyle.Render(entry.Timestamp.Format("2006-01-02 15:04:05.000"))) + b.WriteByte('\n') + rendered++ + } + + // Level. + if entry.Level != "" && rendered < dh { + b.WriteString(detailKeyStyle.Render(" level ") + " " + detailValStyle.Render(entry.Level)) + b.WriteByte('\n') + rendered++ + } + + // Message. + if entry.Message != "" && rendered < dh { + b.WriteString(detailKeyStyle.Render(" message ") + " " + detailValStyle.Render(entry.Message)) + b.WriteByte('\n') + rendered++ + } + + // Format. + if rendered < dh { + b.WriteString(detailKeyStyle.Render(" format ") + " " + detailValStyle.Render(entry.Format.String())) + b.WriteByte('\n') + rendered++ + } + + // Fields. + if len(entry.Fields) > 0 { + keys := make([]string, 0, len(entry.Fields)) + for k := range entry.Fields { + keys = append(keys, k) + } + sortDetailKeys(keys) + for _, k := range keys { + if rendered >= dh { + break + } + label := fmt.Sprintf(" %-10s", k) + b.WriteString(detailKeyStyle.Render(label) + " " + detailValStyle.Render(entry.Fields[k])) + b.WriteByte('\n') + rendered++ + } + } + + // Pad remaining. + for rendered < dh { + b.WriteByte('\n') + rendered++ + } + + return b.String() +} + +// sortDetailKeys sorts keys alphabetically (simple insertion sort). +func sortDetailKeys(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + // WaitForLines returns a tea.Cmd that reads from a source and sends LogMsg // messages to the TUI. Call this to wire a source into the model. func WaitForLines(src source.Source, p *parser.AutoParser, r *Renderer) tea.Cmd { @@ -284,7 +492,7 @@ func WaitForLines(src source.Source, p *parser.AutoParser, r *Renderer) tea.Cmd } entry := p.Parse(line.Line) rendered := r.RenderEntry(entry) - return LogMsg{Rendered: rendered} + return LogMsg{Rendered: rendered, Entry: entry} } } @@ -295,7 +503,7 @@ func ListenForLines(src source.Source, p *parser.AutoParser, r *Renderer, prog * for line := range src.Lines() { entry := p.Parse(line.Line) rendered := r.RenderEntry(entry) - prog.Send(LogMsg{Rendered: rendered}) + prog.Send(LogMsg{Rendered: rendered, Entry: entry}) } }() go func() { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index d7f446e..3d84a76 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -5,6 +5,7 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/clarabennett2626/logpilot/internal/parser" ) func setupModel(width, height int, lines int) Model { @@ -66,26 +67,28 @@ func TestMaxOffsetFewLines(t *testing.T) { func TestScrollDown(t *testing.T) { m := setupModel(80, 24, 100) m.offset = 0 + m.cursor = 0 m.autoScroll = false updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) m = updated.(Model) - if m.offset != 1 { - t.Errorf("offset = %d, want 1 after scroll down", m.offset) + if m.cursor != 1 { + t.Errorf("cursor = %d, want 1 after scroll down", m.cursor) } } func TestScrollUp(t *testing.T) { m := setupModel(80, 24, 100) + m.cursor = 10 m.offset = 10 m.autoScroll = false updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) m = updated.(Model) - if m.offset != 9 { - t.Errorf("offset = %d, want 9 after scroll up", m.offset) + if m.cursor != 9 { + t.Errorf("cursor = %d, want 9 after scroll up", m.cursor) } } @@ -261,6 +264,7 @@ func TestViewWithLines(t *testing.T) { func TestAutoScrollReenableAtBottom(t *testing.T) { m := setupModel(80, 24, 100) m.autoScroll = false + m.cursor = len(m.lines) - 2 m.offset = m.maxOffset() - 1 // Scroll down to bottom. @@ -285,6 +289,95 @@ func TestErrMsg(t *testing.T) { } } +func TestDetailPaneToggle(t *testing.T) { + m := setupModel(80, 24, 10) + m.cursor = 3 + // Add parallel entries. + m.entries = make([]parser.LogEntry, 10) + for i := 0; i < 10; i++ { + m.entries[i] = parser.LogEntry{ + Level: "info", + Message: fmt.Sprintf("line %d", i), + Fields: map[string]string{"key": fmt.Sprintf("val%d", i)}, + Format: parser.FormatJSON, + } + } + + // Press Enter to show detail. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if !m.showDetail { + t.Error("expected showDetail=true after Enter") + } + + // Press Enter again to hide. + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if m.showDetail { + t.Error("expected showDetail=false after second Enter") + } +} + +func TestDetailPaneEsc(t *testing.T) { + m := setupModel(80, 24, 10) + m.entries = make([]parser.LogEntry, 10) + m.showDetail = true + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEscape}) + m = updated.(Model) + if m.showDetail { + t.Error("expected showDetail=false after Esc") + } +} + +func TestCursorClamp(t *testing.T) { + m := setupModel(80, 24, 5) + m.cursor = 4 + m.autoScroll = false + + // Press j — should clamp at 4. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + m = updated.(Model) + if m.cursor != 4 { + t.Errorf("cursor = %d, want 4 (clamped at last line)", m.cursor) + } +} + +func TestViewWithDetailPane(t *testing.T) { + m := setupModel(80, 24, 10) + m.entries = make([]parser.LogEntry, 10) + for i := 0; i < 10; i++ { + m.entries[i] = parser.LogEntry{ + Level: "info", + Message: fmt.Sprintf("msg %d", i), + Fields: map[string]string{"host": "server1"}, + Format: parser.FormatJSON, + } + } + m.cursor = 2 + m.showDetail = true + + v := m.View() + if v == "" { + t.Error("View() should not be empty with detail pane") + } + if !contains(v, "Detail") { + t.Error("View() should contain detail pane header") + } + if !contains(v, "host") { + t.Error("View() should show field keys in detail pane") + } +} + +func TestFilterTextInStatusBar(t *testing.T) { + m := setupModel(80, 24, 5) + m.filterText = "error" + v := m.View() + if !contains(v, "Filter") { + t.Error("status bar should show filter info when filterText is set") + } +} + func contains(s, substr string) bool { return len(s) >= len(substr) && searchString(s, substr) }