@@ -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.
3346type LogMsg struct {
3447 Rendered string
48+ Entry parser.LogEntry
3549}
3650
3751// LogBatchMsg carries multiple rendered log lines at once.
3852type 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.
91139func (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.
100177func (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.
279487func 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