Skip to content

Commit e761b58

Browse files
feat: TUI detail pane + status bar (#15)
Add detail pane with cursor navigation and enhanced status bar for the TUI interface. Closes #13.
1 parent 35941eb commit e761b58

2 files changed

Lines changed: 318 additions & 17 deletions

File tree

internal/tui/model.go

Lines changed: 221 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,31 @@ var (
2727
Background(lipgloss.Color("#333333")).
2828
Bold(true).
2929
Padding(0, 1)
30+
31+
cursorStyle = lipgloss.NewStyle().
32+
Background(lipgloss.Color("#3C3C5C"))
33+
34+
detailBorderStyle = lipgloss.NewStyle().
35+
Foreground(lipgloss.Color("#7D56F4"))
36+
37+
detailKeyStyle = lipgloss.NewStyle().
38+
Foreground(lipgloss.Color("#117")).
39+
Bold(true)
40+
41+
detailValStyle = lipgloss.NewStyle().
42+
Foreground(lipgloss.Color("#252"))
3043
)
3144

3245
// LogMsg carries a new parsed and rendered log line into the TUI.
3346
type LogMsg struct {
3447
Rendered string
48+
Entry parser.LogEntry
3549
}
3650

3751
// LogBatchMsg carries multiple rendered log lines at once.
3852
type LogBatchMsg struct {
39-
Lines []string
53+
Lines []string
54+
Entries []parser.LogEntry
4055
}
4156

4257
// ErrMsg carries a source error into the TUI.
@@ -51,14 +66,22 @@ type Model struct {
5166
ready bool
5267

5368
// Log buffer — stores rendered strings for display.
54-
lines []string
69+
lines []string
70+
entries []parser.LogEntry // parallel to lines; stores parsed entries
5571

5672
// Virtual scrolling state.
5773
offset int // index of the first visible line
5874
autoScroll bool // stick to bottom when new lines arrive
5975

76+
// Cursor and detail pane.
77+
cursor int // index of the highlighted line
78+
showDetail bool // whether the detail pane is visible
79+
6080
// Source info for status bar.
6181
sourceName string
82+
83+
// Filter status for status bar.
84+
filterText string
6285
}
6386

6487
// NewModel creates a new LogPilot TUI model with no sources.
@@ -87,6 +110,31 @@ func (m Model) viewHeight() int {
87110
return h
88111
}
89112

113+
// detailPaneHeight returns the height of the detail pane when visible.
114+
func (m Model) detailPaneHeight() int {
115+
h := m.viewHeight() / 3
116+
if h < 5 {
117+
h = 5
118+
}
119+
if h > 15 {
120+
h = 15
121+
}
122+
return h
123+
}
124+
125+
// logPaneHeight returns the log viewport height when detail pane is visible.
126+
func (m Model) logPaneHeight() int {
127+
if !m.showDetail {
128+
return m.viewHeight()
129+
}
130+
// detail pane takes detailPaneHeight + 1 (border line)
131+
h := m.viewHeight() - m.detailPaneHeight() - 1
132+
if h < 3 {
133+
return 3
134+
}
135+
return h
136+
}
137+
90138
// maxOffset returns the maximum valid scroll offset.
91139
func (m Model) maxOffset() int {
92140
max := len(m.lines) - m.viewHeight()
@@ -96,6 +144,35 @@ func (m Model) maxOffset() int {
96144
return max
97145
}
98146

147+
// clampCursor ensures cursor is within valid bounds.
148+
func (m *Model) clampCursor() {
149+
if m.cursor < 0 {
150+
m.cursor = 0
151+
}
152+
if max := len(m.lines) - 1; m.cursor > max {
153+
if max < 0 {
154+
m.cursor = 0
155+
} else {
156+
m.cursor = max
157+
}
158+
}
159+
}
160+
161+
// scrollToCursor adjusts offset so the cursor is visible.
162+
func (m *Model) scrollToCursor() {
163+
vh := m.viewHeight()
164+
if m.showDetail {
165+
vh = m.logPaneHeight()
166+
}
167+
if m.cursor < m.offset {
168+
m.offset = m.cursor
169+
}
170+
if m.cursor >= m.offset+vh {
171+
m.offset = m.cursor - vh + 1
172+
}
173+
m.clampOffset()
174+
}
175+
99176
// clampOffset ensures offset is within valid bounds.
100177
func (m *Model) clampOffset() {
101178
if m.offset < 0 {
@@ -123,43 +200,66 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
123200
switch msg.String() {
124201
case "q", "ctrl+c":
125202
return m, tea.Quit
203+
case "enter":
204+
if len(m.lines) > 0 {
205+
m.showDetail = !m.showDetail
206+
}
207+
case "esc":
208+
if m.showDetail {
209+
m.showDetail = false
210+
}
126211
case "j", "down":
127212
m.autoScroll = false
128-
m.offset++
129-
m.clampOffset()
213+
m.cursor++
214+
m.clampCursor()
215+
m.scrollToCursor()
130216
if m.isAtBottom() {
131217
m.autoScroll = true
132218
}
133219
case "k", "up":
134220
m.autoScroll = false
135-
m.offset--
136-
m.clampOffset()
221+
m.cursor--
222+
m.clampCursor()
223+
m.scrollToCursor()
137224
case "g", "home":
138225
m.autoScroll = false
226+
m.cursor = 0
139227
m.offset = 0
140228
case "G", "end":
229+
m.cursor = len(m.lines) - 1
230+
if m.cursor < 0 {
231+
m.cursor = 0
232+
}
141233
m.offset = m.maxOffset()
142234
m.autoScroll = true
143235
case "pgdown", "f", "ctrl+f":
144236
m.autoScroll = false
237+
m.cursor += m.viewHeight()
238+
m.clampCursor()
145239
m.offset += m.viewHeight()
146240
m.clampOffset()
147241
if m.isAtBottom() {
148242
m.autoScroll = true
149243
}
150244
case "pgup", "b", "ctrl+b":
151245
m.autoScroll = false
246+
m.cursor -= m.viewHeight()
247+
m.clampCursor()
152248
m.offset -= m.viewHeight()
153249
m.clampOffset()
154250
case "d", "ctrl+d":
155251
m.autoScroll = false
252+
m.cursor += m.viewHeight() / 2
253+
m.clampCursor()
156254
m.offset += m.viewHeight() / 2
157255
m.clampOffset()
158256
if m.isAtBottom() {
159257
m.autoScroll = true
160258
}
161259
case "u", "ctrl+u":
162260
m.autoScroll = false
261+
m.cursor -= m.viewHeight() / 2
262+
m.clampCursor()
163263
m.offset -= m.viewHeight() / 2
164264
m.clampOffset()
165265
}
@@ -175,14 +275,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
175275

176276
case LogMsg:
177277
m.lines = append(m.lines, msg.Rendered)
278+
m.entries = append(m.entries, msg.Entry)
178279
if m.autoScroll {
179280
m.offset = m.maxOffset()
281+
m.cursor = len(m.lines) - 1
282+
if m.cursor < 0 {
283+
m.cursor = 0
284+
}
180285
}
181286

182287
case LogBatchMsg:
183288
m.lines = append(m.lines, msg.Lines...)
289+
m.entries = append(m.entries, msg.Entries...)
184290
if m.autoScroll {
185291
m.offset = m.maxOffset()
292+
m.cursor = len(m.lines) - 1
293+
if m.cursor < 0 {
294+
m.cursor = 0
295+
}
186296
}
187297

188298
case ErrMsg:
@@ -209,7 +319,7 @@ func (m Model) View() string {
209319
b.WriteByte('\n')
210320

211321
// Log viewport — virtual scrolling: only render visible slice.
212-
vh := m.viewHeight()
322+
vh := m.logPaneHeight()
213323
if len(m.lines) == 0 {
214324
// Empty state.
215325
for i := 0; i < vh; i++ {
@@ -229,10 +339,14 @@ func (m Model) View() string {
229339
if start < 0 {
230340
start = 0
231341
}
232-
// Render visible lines.
342+
// Render visible lines with cursor highlight.
233343
rendered := 0
234344
for i := start; i < end; i++ {
235-
b.WriteString(m.lines[i])
345+
line := m.lines[i]
346+
if i == m.cursor {
347+
line = cursorStyle.Render(line)
348+
}
349+
b.WriteString(line)
236350
b.WriteByte('\n')
237351
rendered++
238352
}
@@ -242,6 +356,11 @@ func (m Model) View() string {
242356
}
243357
}
244358

359+
// Detail pane.
360+
if m.showDetail && len(m.entries) > 0 && m.cursor < len(m.entries) {
361+
b.WriteString(m.renderDetailPane())
362+
}
363+
245364
// Status bar.
246365
total := len(m.lines)
247366
scrollInfo := "bottom"
@@ -262,18 +381,107 @@ func (m Model) View() string {
262381
right := statusKeyStyle.Render("Pos:") + statusBarStyle.Render(fmt.Sprintf(" %s ", scrollInfo))
263382
srcInfo := statusKeyStyle.Render("Src:") + statusBarStyle.Render(fmt.Sprintf(" %s ", src))
264383

265-
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) - lipgloss.Width(srcInfo)
384+
// Filter status.
385+
filterInfo := ""
386+
if m.filterText != "" {
387+
filterInfo = statusKeyStyle.Render("Filter:") + statusBarStyle.Render(fmt.Sprintf(" %s ", m.filterText))
388+
}
389+
390+
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) - lipgloss.Width(srcInfo) - lipgloss.Width(filterInfo)
266391
if gap < 0 {
267392
gap = 0
268393
}
269-
statusLine := left + srcInfo + strings.Repeat(" ", gap) + right
394+
statusLine := left + srcInfo + filterInfo + strings.Repeat(" ", gap) + right
270395
// Fill background.
271396
statusLine = statusBarStyle.Render(statusLine)
272397
b.WriteString(statusLine)
273398

274399
return b.String()
275400
}
276401

402+
// renderDetailPane renders the detail pane for the selected log entry.
403+
func (m Model) renderDetailPane() string {
404+
var b strings.Builder
405+
406+
// Separator line.
407+
sep := detailBorderStyle.Render(strings.Repeat("─", m.width))
408+
b.WriteString(sep)
409+
b.WriteByte('\n')
410+
411+
entry := m.entries[m.cursor]
412+
dh := m.detailPaneHeight()
413+
rendered := 0
414+
415+
// Header.
416+
header := detailBorderStyle.Render("▼ Detail")
417+
b.WriteString(header)
418+
b.WriteByte('\n')
419+
rendered++
420+
421+
// Timestamp.
422+
if !entry.Timestamp.IsZero() && rendered < dh {
423+
b.WriteString(detailKeyStyle.Render(" timestamp") + " " + detailValStyle.Render(entry.Timestamp.Format("2006-01-02 15:04:05.000")))
424+
b.WriteByte('\n')
425+
rendered++
426+
}
427+
428+
// Level.
429+
if entry.Level != "" && rendered < dh {
430+
b.WriteString(detailKeyStyle.Render(" level ") + " " + detailValStyle.Render(entry.Level))
431+
b.WriteByte('\n')
432+
rendered++
433+
}
434+
435+
// Message.
436+
if entry.Message != "" && rendered < dh {
437+
b.WriteString(detailKeyStyle.Render(" message ") + " " + detailValStyle.Render(entry.Message))
438+
b.WriteByte('\n')
439+
rendered++
440+
}
441+
442+
// Format.
443+
if rendered < dh {
444+
b.WriteString(detailKeyStyle.Render(" format ") + " " + detailValStyle.Render(entry.Format.String()))
445+
b.WriteByte('\n')
446+
rendered++
447+
}
448+
449+
// Fields.
450+
if len(entry.Fields) > 0 {
451+
keys := make([]string, 0, len(entry.Fields))
452+
for k := range entry.Fields {
453+
keys = append(keys, k)
454+
}
455+
sortDetailKeys(keys)
456+
for _, k := range keys {
457+
if rendered >= dh {
458+
break
459+
}
460+
label := fmt.Sprintf(" %-10s", k)
461+
b.WriteString(detailKeyStyle.Render(label) + " " + detailValStyle.Render(entry.Fields[k]))
462+
b.WriteByte('\n')
463+
rendered++
464+
}
465+
}
466+
467+
// Pad remaining.
468+
for rendered < dh {
469+
b.WriteByte('\n')
470+
rendered++
471+
}
472+
473+
return b.String()
474+
}
475+
476+
// sortDetailKeys sorts keys alphabetically (simple insertion sort).
477+
func sortDetailKeys(s []string) {
478+
for i := 1; i < len(s); i++ {
479+
for j := i; j > 0 && s[j] < s[j-1]; j-- {
480+
s[j], s[j-1] = s[j-1], s[j]
481+
}
482+
}
483+
}
484+
277485
// WaitForLines returns a tea.Cmd that reads from a source and sends LogMsg
278486
// messages to the TUI. Call this to wire a source into the model.
279487
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
284492
}
285493
entry := p.Parse(line.Line)
286494
rendered := r.RenderEntry(entry)
287-
return LogMsg{Rendered: rendered}
495+
return LogMsg{Rendered: rendered, Entry: entry}
288496
}
289497
}
290498

@@ -295,7 +503,7 @@ func ListenForLines(src source.Source, p *parser.AutoParser, r *Renderer, prog *
295503
for line := range src.Lines() {
296504
entry := p.Parse(line.Line)
297505
rendered := r.RenderEntry(entry)
298-
prog.Send(LogMsg{Rendered: rendered})
506+
prog.Send(LogMsg{Rendered: rendered, Entry: entry})
299507
}
300508
}()
301509
go func() {

0 commit comments

Comments
 (0)