Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 221 additions & 13 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand Down Expand Up @@ -123,43 +200,66 @@ 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() {
m.autoScroll = true
}
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() {
m.autoScroll = true
}
case "u", "ctrl+u":
m.autoScroll = false
m.cursor -= m.viewHeight() / 2
m.clampCursor()
m.offset -= m.viewHeight() / 2
m.clampOffset()
}
Expand All @@ -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:
Expand All @@ -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++ {
Expand All @@ -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++
}
Expand All @@ -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"
Expand All @@ -262,18 +381,107 @@ 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)

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 {
Expand All @@ -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}
}
}

Expand All @@ -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() {
Expand Down
Loading
Loading