@@ -2,9 +2,12 @@ package tui
22
33import (
44 "fmt"
5+ "strings"
56
67 tea "github.com/charmbracelet/bubbletea"
78 "github.com/charmbracelet/lipgloss"
9+ "github.com/clarabennett2626/logpilot/internal/parser"
10+ "github.com/clarabennett2626/logpilot/internal/source"
811)
912
1013var (
@@ -14,20 +17,98 @@ var (
1417 Background (lipgloss .Color ("#7D56F4" )).
1518 Padding (0 , 1 )
1619
17- statusStyle = lipgloss .NewStyle ().
18- Foreground (lipgloss .Color ("#666666" ))
20+ statusBarStyle = lipgloss .NewStyle ().
21+ Foreground (lipgloss .Color ("#FAFAFA" )).
22+ Background (lipgloss .Color ("#333333" )).
23+ Padding (0 , 1 )
24+
25+ statusKeyStyle = lipgloss .NewStyle ().
26+ Foreground (lipgloss .Color ("#7D56F4" )).
27+ Background (lipgloss .Color ("#333333" )).
28+ Bold (true ).
29+ Padding (0 , 1 )
1930)
2031
32+ // LogMsg carries a new parsed and rendered log line into the TUI.
33+ type LogMsg struct {
34+ Rendered string
35+ }
36+
37+ // LogBatchMsg carries multiple rendered log lines at once.
38+ type LogBatchMsg struct {
39+ Lines []string
40+ }
41+
42+ // ErrMsg carries a source error into the TUI.
43+ type ErrMsg struct {
44+ Err error
45+ }
46+
2147// Model is the main TUI model for LogPilot.
2248type Model struct {
2349 width int
2450 height int
2551 ready bool
52+
53+ // Log buffer — stores rendered strings for display.
54+ lines []string
55+
56+ // Virtual scrolling state.
57+ offset int // index of the first visible line
58+ autoScroll bool // stick to bottom when new lines arrive
59+
60+ // Source info for status bar.
61+ sourceName string
2662}
2763
28- // NewModel creates a new LogPilot TUI model.
64+ // NewModel creates a new LogPilot TUI model with no sources .
2965func NewModel () Model {
30- return Model {}
66+ return Model {
67+ autoScroll : true ,
68+ }
69+ }
70+
71+ // NewModelWithSource creates a TUI model wired to a log source.
72+ func NewModelWithSource (src source.Source , sourceName string ) Model {
73+ return Model {
74+ autoScroll : true ,
75+ sourceName : sourceName ,
76+ }
77+ }
78+
79+ // viewHeight returns the number of lines available for log display
80+ // (total height minus title bar and status bar).
81+ func (m Model ) viewHeight () int {
82+ // 1 line title + 1 blank + 1 status bar = 3 overhead lines
83+ h := m .height - 3
84+ if h < 1 {
85+ return 1
86+ }
87+ return h
88+ }
89+
90+ // maxOffset returns the maximum valid scroll offset.
91+ func (m Model ) maxOffset () int {
92+ max := len (m .lines ) - m .viewHeight ()
93+ if max < 0 {
94+ return 0
95+ }
96+ return max
97+ }
98+
99+ // clampOffset ensures offset is within valid bounds.
100+ func (m * Model ) clampOffset () {
101+ if m .offset < 0 {
102+ m .offset = 0
103+ }
104+ if max := m .maxOffset (); m .offset > max {
105+ m .offset = max
106+ }
107+ }
108+
109+ // isAtBottom returns true if the viewport is scrolled to the bottom.
110+ func (m Model ) isAtBottom () bool {
111+ return m .offset >= m .maxOffset ()
31112}
32113
33114// Init initializes the model.
@@ -42,11 +123,74 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
42123 switch msg .String () {
43124 case "q" , "ctrl+c" :
44125 return m , tea .Quit
126+ case "j" , "down" :
127+ m .autoScroll = false
128+ m .offset ++
129+ m .clampOffset ()
130+ if m .isAtBottom () {
131+ m .autoScroll = true
132+ }
133+ case "k" , "up" :
134+ m .autoScroll = false
135+ m .offset --
136+ m .clampOffset ()
137+ case "g" , "home" :
138+ m .autoScroll = false
139+ m .offset = 0
140+ case "G" , "end" :
141+ m .offset = m .maxOffset ()
142+ m .autoScroll = true
143+ case "pgdown" , "f" , "ctrl+f" :
144+ m .autoScroll = false
145+ m .offset += m .viewHeight ()
146+ m .clampOffset ()
147+ if m .isAtBottom () {
148+ m .autoScroll = true
149+ }
150+ case "pgup" , "b" , "ctrl+b" :
151+ m .autoScroll = false
152+ m .offset -= m .viewHeight ()
153+ m .clampOffset ()
154+ case "d" , "ctrl+d" :
155+ m .autoScroll = false
156+ m .offset += m .viewHeight () / 2
157+ m .clampOffset ()
158+ if m .isAtBottom () {
159+ m .autoScroll = true
160+ }
161+ case "u" , "ctrl+u" :
162+ m .autoScroll = false
163+ m .offset -= m .viewHeight () / 2
164+ m .clampOffset ()
45165 }
166+
46167 case tea.WindowSizeMsg :
47168 m .width = msg .Width
48169 m .height = msg .Height
49170 m .ready = true
171+ if m .autoScroll {
172+ m .offset = m .maxOffset ()
173+ }
174+ m .clampOffset ()
175+
176+ case LogMsg :
177+ m .lines = append (m .lines , msg .Rendered )
178+ if m .autoScroll {
179+ m .offset = m .maxOffset ()
180+ }
181+
182+ case LogBatchMsg :
183+ m .lines = append (m .lines , msg .Lines ... )
184+ if m .autoScroll {
185+ m .offset = m .maxOffset ()
186+ }
187+
188+ case ErrMsg :
189+ // Show error as a log line.
190+ m .lines = append (m .lines , fmt .Sprintf ("ERROR: %v" , msg .Err ))
191+ if m .autoScroll {
192+ m .offset = m .maxOffset ()
193+ }
50194 }
51195 return m , nil
52196}
@@ -57,8 +201,106 @@ func (m Model) View() string {
57201 return "Loading..."
58202 }
59203
204+ var b strings.Builder
205+
206+ // Title bar.
60207 title := titleStyle .Render ("LogPilot" )
61- status := statusStyle .Render (fmt .Sprintf ("Terminal: %dx%d | Press 'q' to quit" , m .width , m .height ))
208+ b .WriteString (title )
209+ b .WriteByte ('\n' )
62210
63- return fmt .Sprintf ("%s\n \n No log sources connected.\n Usage: logpilot <file.log>\n \n %s" , title , status )
211+ // Log viewport — virtual scrolling: only render visible slice.
212+ vh := m .viewHeight ()
213+ if len (m .lines ) == 0 {
214+ // Empty state.
215+ for i := 0 ; i < vh ; i ++ {
216+ if i == vh / 2 - 1 {
217+ b .WriteString (" No log entries yet." )
218+ } else if i == vh / 2 {
219+ b .WriteString (" Waiting for input..." )
220+ }
221+ b .WriteByte ('\n' )
222+ }
223+ } else {
224+ end := m .offset + vh
225+ if end > len (m .lines ) {
226+ end = len (m .lines )
227+ }
228+ start := m .offset
229+ if start < 0 {
230+ start = 0
231+ }
232+ // Render visible lines.
233+ rendered := 0
234+ for i := start ; i < end ; i ++ {
235+ b .WriteString (m .lines [i ])
236+ b .WriteByte ('\n' )
237+ rendered ++
238+ }
239+ // Pad remaining lines.
240+ for i := rendered ; i < vh ; i ++ {
241+ b .WriteByte ('\n' )
242+ }
243+ }
244+
245+ // Status bar.
246+ total := len (m .lines )
247+ scrollInfo := "bottom"
248+ if total > 0 && ! m .isAtBottom () {
249+ pct := 0
250+ if m .maxOffset () > 0 {
251+ pct = m .offset * 100 / m .maxOffset ()
252+ }
253+ scrollInfo = fmt .Sprintf ("%d%%" , pct )
254+ }
255+
256+ src := m .sourceName
257+ if src == "" {
258+ src = "stdin"
259+ }
260+
261+ left := statusKeyStyle .Render ("Lines:" ) + statusBarStyle .Render (fmt .Sprintf (" %d " , total ))
262+ right := statusKeyStyle .Render ("Pos:" ) + statusBarStyle .Render (fmt .Sprintf (" %s " , scrollInfo ))
263+ srcInfo := statusKeyStyle .Render ("Src:" ) + statusBarStyle .Render (fmt .Sprintf (" %s " , src ))
264+
265+ gap := m .width - lipgloss .Width (left ) - lipgloss .Width (right ) - lipgloss .Width (srcInfo )
266+ if gap < 0 {
267+ gap = 0
268+ }
269+ statusLine := left + srcInfo + strings .Repeat (" " , gap ) + right
270+ // Fill background.
271+ statusLine = statusBarStyle .Render (statusLine )
272+ b .WriteString (statusLine )
273+
274+ return b .String ()
275+ }
276+
277+ // WaitForLines returns a tea.Cmd that reads from a source and sends LogMsg
278+ // messages to the TUI. Call this to wire a source into the model.
279+ func WaitForLines (src source.Source , p * parser.AutoParser , r * Renderer ) tea.Cmd {
280+ return func () tea.Msg {
281+ line , ok := <- src .Lines ()
282+ if ! ok {
283+ return nil
284+ }
285+ entry := p .Parse (line .Line )
286+ rendered := r .RenderEntry (entry )
287+ return LogMsg {Rendered : rendered }
288+ }
289+ }
290+
291+ // ListenForLines returns a tea.Cmd that continuously reads from a source
292+ // and sends lines to the program. Use with tea.Program.Send from a goroutine.
293+ func ListenForLines (src source.Source , p * parser.AutoParser , r * Renderer , prog * tea.Program ) {
294+ go func () {
295+ for line := range src .Lines () {
296+ entry := p .Parse (line .Line )
297+ rendered := r .RenderEntry (entry )
298+ prog .Send (LogMsg {Rendered : rendered })
299+ }
300+ }()
301+ go func () {
302+ for err := range src .Errors () {
303+ prog .Send (ErrMsg {Err : err })
304+ }
305+ }()
64306}
0 commit comments