diff --git a/cmd/logpilot/main.go b/cmd/logpilot/main.go index 6ad01ee..f187373 100644 --- a/cmd/logpilot/main.go +++ b/cmd/logpilot/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "strings" "syscall" tea "github.com/charmbracelet/bubbletea" @@ -34,13 +35,51 @@ func main() { return } - p := tea.NewProgram(tui.NewModel(), tea.WithAltScreen()) - if _, err := p.Run(); err != nil { + // TUI mode — files given as args. + files := os.Args[1:] + if err := runTUIMode(files); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } +// runTUIMode starts the interactive TUI with file sources. +func runTUIMode(files []string) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sourceName := "no source" + var src source.Source + + if len(files) > 0 { + sourceName = strings.Join(files, ", ") + fileSrc := source.NewFileSource(source.FileConfig{ + Patterns: files, + TailLines: 1000, + }) + if err := fileSrc.Start(ctx); err != nil { + return fmt.Errorf("starting file source: %w", err) + } + defer fileSrc.Stop() + src = fileSrc + } + + model := tui.NewModelWithSource(src, sourceName) + p := tea.NewProgram(model, tea.WithAltScreen()) + + // Wire source lines into the TUI via Program.Send. + if src != nil { + autoParser := parser.NewAutoParser() + renderer := tui.NewRenderer(tui.DefaultConfig()) + tui.ListenForLines(src, autoParser, renderer, p) + } + + if _, err := p.Run(); err != nil { + return err + } + return nil +} + // runPipeMode reads from stdin, parses each line, and renders output to stdout. func runPipeMode() error { ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/tui/model.go b/internal/tui/model.go index 5011127..55e5574 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,9 +2,12 @@ package tui import ( "fmt" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/clarabennett2626/logpilot/internal/parser" + "github.com/clarabennett2626/logpilot/internal/source" ) var ( @@ -14,20 +17,98 @@ var ( Background(lipgloss.Color("#7D56F4")). Padding(0, 1) - statusStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#666666")) + statusBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FAFAFA")). + Background(lipgloss.Color("#333333")). + Padding(0, 1) + + statusKeyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7D56F4")). + Background(lipgloss.Color("#333333")). + Bold(true). + Padding(0, 1) ) +// LogMsg carries a new parsed and rendered log line into the TUI. +type LogMsg struct { + Rendered string +} + +// LogBatchMsg carries multiple rendered log lines at once. +type LogBatchMsg struct { + Lines []string +} + +// ErrMsg carries a source error into the TUI. +type ErrMsg struct { + Err error +} + // Model is the main TUI model for LogPilot. type Model struct { width int height int ready bool + + // Log buffer — stores rendered strings for display. + lines []string + + // Virtual scrolling state. + offset int // index of the first visible line + autoScroll bool // stick to bottom when new lines arrive + + // Source info for status bar. + sourceName string } -// NewModel creates a new LogPilot TUI model. +// NewModel creates a new LogPilot TUI model with no sources. func NewModel() Model { - return Model{} + return Model{ + autoScroll: true, + } +} + +// NewModelWithSource creates a TUI model wired to a log source. +func NewModelWithSource(src source.Source, sourceName string) Model { + return Model{ + autoScroll: true, + sourceName: sourceName, + } +} + +// viewHeight returns the number of lines available for log display +// (total height minus title bar and status bar). +func (m Model) viewHeight() int { + // 1 line title + 1 blank + 1 status bar = 3 overhead lines + h := m.height - 3 + if h < 1 { + return 1 + } + return h +} + +// maxOffset returns the maximum valid scroll offset. +func (m Model) maxOffset() int { + max := len(m.lines) - m.viewHeight() + if max < 0 { + return 0 + } + return max +} + +// clampOffset ensures offset is within valid bounds. +func (m *Model) clampOffset() { + if m.offset < 0 { + m.offset = 0 + } + if max := m.maxOffset(); m.offset > max { + m.offset = max + } +} + +// isAtBottom returns true if the viewport is scrolled to the bottom. +func (m Model) isAtBottom() bool { + return m.offset >= m.maxOffset() } // Init initializes the model. @@ -42,11 +123,74 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": return m, tea.Quit + case "j", "down": + m.autoScroll = false + m.offset++ + m.clampOffset() + if m.isAtBottom() { + m.autoScroll = true + } + case "k", "up": + m.autoScroll = false + m.offset-- + m.clampOffset() + case "g", "home": + m.autoScroll = false + m.offset = 0 + case "G", "end": + m.offset = m.maxOffset() + m.autoScroll = true + case "pgdown", "f", "ctrl+f": + m.autoScroll = false + m.offset += m.viewHeight() + m.clampOffset() + if m.isAtBottom() { + m.autoScroll = true + } + case "pgup", "b", "ctrl+b": + m.autoScroll = false + m.offset -= m.viewHeight() + m.clampOffset() + case "d", "ctrl+d": + m.autoScroll = false + m.offset += m.viewHeight() / 2 + m.clampOffset() + if m.isAtBottom() { + m.autoScroll = true + } + case "u", "ctrl+u": + m.autoScroll = false + m.offset -= m.viewHeight() / 2 + m.clampOffset() } + case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.ready = true + if m.autoScroll { + m.offset = m.maxOffset() + } + m.clampOffset() + + case LogMsg: + m.lines = append(m.lines, msg.Rendered) + if m.autoScroll { + m.offset = m.maxOffset() + } + + case LogBatchMsg: + m.lines = append(m.lines, msg.Lines...) + if m.autoScroll { + m.offset = m.maxOffset() + } + + case ErrMsg: + // Show error as a log line. + m.lines = append(m.lines, fmt.Sprintf("ERROR: %v", msg.Err)) + if m.autoScroll { + m.offset = m.maxOffset() + } } return m, nil } @@ -57,8 +201,106 @@ func (m Model) View() string { return "Loading..." } + var b strings.Builder + + // Title bar. title := titleStyle.Render("LogPilot") - status := statusStyle.Render(fmt.Sprintf("Terminal: %dx%d | Press 'q' to quit", m.width, m.height)) + b.WriteString(title) + b.WriteByte('\n') - return fmt.Sprintf("%s\n\n No log sources connected.\n Usage: logpilot \n\n%s", title, status) + // Log viewport — virtual scrolling: only render visible slice. + vh := m.viewHeight() + if len(m.lines) == 0 { + // Empty state. + for i := 0; i < vh; i++ { + if i == vh/2-1 { + b.WriteString(" No log entries yet.") + } else if i == vh/2 { + b.WriteString(" Waiting for input...") + } + b.WriteByte('\n') + } + } else { + end := m.offset + vh + if end > len(m.lines) { + end = len(m.lines) + } + start := m.offset + if start < 0 { + start = 0 + } + // Render visible lines. + rendered := 0 + for i := start; i < end; i++ { + b.WriteString(m.lines[i]) + b.WriteByte('\n') + rendered++ + } + // Pad remaining lines. + for i := rendered; i < vh; i++ { + b.WriteByte('\n') + } + } + + // Status bar. + total := len(m.lines) + scrollInfo := "bottom" + if total > 0 && !m.isAtBottom() { + pct := 0 + if m.maxOffset() > 0 { + pct = m.offset * 100 / m.maxOffset() + } + scrollInfo = fmt.Sprintf("%d%%", pct) + } + + src := m.sourceName + if src == "" { + src = "stdin" + } + + left := statusKeyStyle.Render("Lines:") + statusBarStyle.Render(fmt.Sprintf(" %d ", total)) + 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) + if gap < 0 { + gap = 0 + } + statusLine := left + srcInfo + strings.Repeat(" ", gap) + right + // Fill background. + statusLine = statusBarStyle.Render(statusLine) + b.WriteString(statusLine) + + return b.String() +} + +// 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 { + return func() tea.Msg { + line, ok := <-src.Lines() + if !ok { + return nil + } + entry := p.Parse(line.Line) + rendered := r.RenderEntry(entry) + return LogMsg{Rendered: rendered} + } +} + +// ListenForLines returns a tea.Cmd that continuously reads from a source +// and sends lines to the program. Use with tea.Program.Send from a goroutine. +func ListenForLines(src source.Source, p *parser.AutoParser, r *Renderer, prog *tea.Program) { + go func() { + for line := range src.Lines() { + entry := p.Parse(line.Line) + rendered := r.RenderEntry(entry) + prog.Send(LogMsg{Rendered: rendered}) + } + }() + go func() { + for err := range src.Errors() { + prog.Send(ErrMsg{Err: err}) + } + }() } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..d7f446e --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,299 @@ +package tui + +import ( + "fmt" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func setupModel(width, height int, lines int) Model { + m := NewModel() + // Simulate window size. + m.width = width + m.height = height + m.ready = true + // Add lines. + for i := 0; i < lines; i++ { + m.lines = append(m.lines, fmt.Sprintf("line %d", i)) + } + if m.autoScroll { + m.offset = m.maxOffset() + } + return m +} + +func TestNewModel(t *testing.T) { + m := NewModel() + if !m.autoScroll { + t.Error("expected autoScroll to be true by default") + } + if len(m.lines) != 0 { + t.Error("expected empty lines buffer") + } +} + +func TestViewHeight(t *testing.T) { + m := setupModel(80, 24, 0) + // height=24, overhead=3 → viewHeight=21 + if vh := m.viewHeight(); vh != 21 { + t.Errorf("viewHeight() = %d, want 21", vh) + } +} + +func TestViewHeightMinimum(t *testing.T) { + m := setupModel(80, 2, 0) + if vh := m.viewHeight(); vh < 1 { + t.Errorf("viewHeight() = %d, want >= 1", vh) + } +} + +func TestMaxOffset(t *testing.T) { + m := setupModel(80, 24, 100) + // viewHeight=21, 100 lines → maxOffset=79 + if max := m.maxOffset(); max != 79 { + t.Errorf("maxOffset() = %d, want 79", max) + } +} + +func TestMaxOffsetFewLines(t *testing.T) { + m := setupModel(80, 24, 5) + if max := m.maxOffset(); max != 0 { + t.Errorf("maxOffset() = %d, want 0 (fewer lines than viewport)", max) + } +} + +func TestScrollDown(t *testing.T) { + m := setupModel(80, 24, 100) + m.offset = 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) + } +} + +func TestScrollUp(t *testing.T) { + m := setupModel(80, 24, 100) + 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) + } +} + +func TestScrollUpClampsAtZero(t *testing.T) { + m := setupModel(80, 24, 100) + m.offset = 0 + m.autoScroll = false + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) + m = updated.(Model) + + if m.offset != 0 { + t.Errorf("offset = %d, want 0 (clamped)", m.offset) + } +} + +func TestScrollToTop(t *testing.T) { + m := setupModel(80, 24, 100) + m.offset = 50 + m.autoScroll = false + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}) + m = updated.(Model) + + if m.offset != 0 { + t.Errorf("offset = %d, want 0 after 'g'", m.offset) + } + if m.autoScroll { + t.Error("autoScroll should be false after scrolling to top") + } +} + +func TestScrollToBottom(t *testing.T) { + m := setupModel(80, 24, 100) + m.offset = 0 + m.autoScroll = false + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) + m = updated.(Model) + + if m.offset != m.maxOffset() { + t.Errorf("offset = %d, want %d after 'G'", m.offset, m.maxOffset()) + } + if !m.autoScroll { + t.Error("autoScroll should be true after scrolling to bottom") + } +} + +func TestPageDown(t *testing.T) { + m := setupModel(80, 24, 100) + m.offset = 0 + m.autoScroll = false + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + m = updated.(Model) + + if m.offset != 21 { + t.Errorf("offset = %d, want 21 after page down", m.offset) + } +} + +func TestPageUp(t *testing.T) { + m := setupModel(80, 24, 100) + m.offset = 50 + m.autoScroll = false + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgUp}) + m = updated.(Model) + + if m.offset != 29 { + t.Errorf("offset = %d, want 29 after page up", m.offset) + } +} + +func TestAutoScrollOnNewLine(t *testing.T) { + m := setupModel(80, 24, 10) + // autoScroll is true, so new lines should keep offset at bottom. + updated, _ := m.Update(LogMsg{Rendered: "new line"}) + m = updated.(Model) + + if m.offset != m.maxOffset() { + t.Errorf("offset = %d, want %d (auto-scroll to bottom)", m.offset, m.maxOffset()) + } + if len(m.lines) != 11 { + t.Errorf("lines count = %d, want 11", len(m.lines)) + } +} + +func TestNoAutoScrollWhenScrolledUp(t *testing.T) { + m := setupModel(80, 24, 50) + m.autoScroll = false + m.offset = 5 + + updated, _ := m.Update(LogMsg{Rendered: "new line"}) + m = updated.(Model) + + if m.offset != 5 { + t.Errorf("offset = %d, want 5 (should not auto-scroll)", m.offset) + } +} + +func TestLogBatchMsg(t *testing.T) { + m := setupModel(80, 24, 0) + batch := LogBatchMsg{Lines: []string{"a", "b", "c"}} + + updated, _ := m.Update(batch) + m = updated.(Model) + + if len(m.lines) != 3 { + t.Errorf("lines count = %d, want 3", len(m.lines)) + } +} + +func TestWindowResize(t *testing.T) { + m := setupModel(80, 24, 100) + m.autoScroll = true + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = updated.(Model) + + if m.width != 120 || m.height != 40 { + t.Errorf("dimensions = %dx%d, want 120x40", m.width, m.height) + } + if m.offset != m.maxOffset() { + t.Errorf("offset = %d, want %d after resize with autoScroll", m.offset, m.maxOffset()) + } +} + +func TestWindowResizeNoAutoScroll(t *testing.T) { + m := setupModel(80, 24, 100) + m.autoScroll = false + m.offset = 10 + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = updated.(Model) + + // Offset should be clamped but not forced to bottom. + if m.offset != 10 { + t.Errorf("offset = %d, want 10 after resize without autoScroll", m.offset) + } +} + +func TestViewNotReady(t *testing.T) { + m := NewModel() + if v := m.View(); v != "Loading..." { + t.Errorf("View() = %q, want 'Loading...'", v) + } +} + +func TestViewEmptyState(t *testing.T) { + m := setupModel(80, 24, 0) + v := m.View() + if v == "" { + t.Error("View() should not be empty when ready") + } +} + +func TestViewWithLines(t *testing.T) { + m := setupModel(80, 24, 5) + v := m.View() + if v == "" { + t.Error("View() should not be empty with lines") + } + // Should contain the lines. + for i := 0; i < 5; i++ { + expected := fmt.Sprintf("line %d", i) + if !contains(v, expected) { + t.Errorf("View() should contain %q", expected) + } + } +} + +func TestAutoScrollReenableAtBottom(t *testing.T) { + m := setupModel(80, 24, 100) + m.autoScroll = false + m.offset = m.maxOffset() - 1 + + // Scroll down to bottom. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + m = updated.(Model) + + if !m.autoScroll { + t.Error("autoScroll should re-enable when scrolled to bottom") + } +} + +func TestErrMsg(t *testing.T) { + m := setupModel(80, 24, 0) + updated, _ := m.Update(ErrMsg{Err: fmt.Errorf("test error")}) + m = updated.(Model) + + if len(m.lines) != 1 { + t.Fatalf("lines count = %d, want 1", len(m.lines)) + } + if !contains(m.lines[0], "test error") { + t.Errorf("error line = %q, should contain 'test error'", m.lines[0]) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}