tui.go

v0.6.0
Doc Versions Source
1
package logreader
2
3
import (
4
	"fmt"
5
	"strings"
6
7
	"github.com/charmbracelet/bubbles/viewport"
8
	tea "github.com/charmbracelet/bubbletea"
9
	"github.com/charmbracelet/lipgloss"
10
)
11
12
// focus tracks which pane has keyboard focus.
13
type focus int
14
15
const (
16
	focusList   focus = iota
17
	focusDetail focus = iota
18
)
19
20
// With RoundedBorder + Padding(0,1):
21
//   lipgloss.Width(W) → outer display width = W + 2 (borders)
22
//                      → usable text columns = W - 2 (padding)
23
const (
24
	borderCols  = 2 // left + right border
25
	paddingCols = 2 // Padding(0,1) → 1 left + 1 right
26
)
27
28
// Styles
29
var (
30
	paneStyle = lipgloss.NewStyle().
31
			Border(lipgloss.RoundedBorder()).
32
			BorderForeground(lipgloss.Color("63")).
33
			Padding(0, 1)
34
35
	paneActiveStyle = lipgloss.NewStyle().
36
			Border(lipgloss.RoundedBorder()).
37
			BorderForeground(lipgloss.Color("42")).
38
			Padding(0, 1)
39
40
	selectedStyle = lipgloss.NewStyle().
41
			Background(lipgloss.Color("63")).
42
			Foreground(lipgloss.Color("230")).
43
			Bold(true)
44
45
	kindInStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)  // green
46
	kindOutStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Bold(true)  // blue
47
	kindResStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) // orange
48
	kindStrStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("170")).Bold(true) // purple
49
	kindInfoStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("117"))            // light blue
50
	kindWarnStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("220"))            // yellow
51
	kindErrStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) // red
52
	kindDebugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))            // gray
53
	kindLogStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))            // gray
54
55
	timeStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
56
	sepStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
57
	titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
58
	helpStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
59
)
60
61
func kindStyle(k EntryKind) lipgloss.Style {
62
	switch k {
63
	case KindIncoming:
64
		return kindInStyle
65
	case KindOutgoing:
66
		return kindOutStyle
67
	case KindResponse:
68
		return kindResStyle
69
	case KindStream:
70
		return kindStrStyle
71
	case KindInfo:
72
		return kindInfoStyle
73
	case KindWarn:
74
		return kindWarnStyle
75
	case KindError:
76
		return kindErrStyle
77
	case KindDebug:
78
		return kindDebugStyle
79
	default:
80
		return kindLogStyle
81
	}
82
}
83
84
var (
85
	searchStyle      = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
86
	searchInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
87
	matchHighlight   = lipgloss.NewStyle().Background(lipgloss.Color("214")).Foreground(lipgloss.Color("0")).Bold(true)
88
	noMatchStyle     = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
89
)
90
91
// Model is the bubbletea model for the log reader TUI.
92
type Model struct {
93
	entries  []Entry
94
	cursor   int
95
	offset   int // scroll offset for list
96
	viewport viewport.Model
97
	focus    focus
98
	width    int
99
	height   int
100
	ready    bool
101
102
	// Search state.
103
	searching   bool   // search input is active
104
	searchQuery string // confirmed search query (used for n/N)
105
	searchInput string // text being typed in search mode
106
}
107
108
// New creates a new log reader TUI model.
109
func New(entries []Entry) Model {
110
	return Model{
111
		entries: entries,
112
		focus:   focusList,
113
	}
114
}
115
116
func (m Model) Init() tea.Cmd {
117
	return nil
118
}
119
120
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
121
	switch msg := msg.(type) {
122
	case tea.KeyMsg:
123
		// Search input mode takes priority.
124
		if m.searching {
125
			return m.updateSearch(msg)
126
		}
127
128
		switch msg.String() {
129
		case "q", "ctrl+c":
130
			return m, tea.Quit
131
		case "tab":
132
			if m.focus == focusList {
133
				m.focus = focusDetail
134
			} else {
135
				m.focus = focusList
136
			}
137
			return m, nil
138
		}
139
140
		if m.focus == focusList {
141
			return m.updateList(msg)
142
		}
143
		return m.updateDetail(msg)
144
145
	case tea.WindowSizeMsg:
146
		m.width = msg.Width
147
		m.height = msg.Height
148
		m.ready = true
149
		m.viewport = viewport.New(m.detailTextWidth(), m.contentHeight())
150
		m.viewport.Style = lipgloss.NewStyle()
151
		m.updateViewport()
152
	}
153
154
	return m, nil
155
}
156
157
func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
158
	switch msg.String() {
159
	case "up":
160
		if m.cursor > 0 {
161
			m.cursor--
162
			m.ensureVisible()
163
			m.updateViewport()
164
		}
165
	case "down":
166
		if m.cursor < len(m.entries)-1 {
167
			m.cursor++
168
			m.ensureVisible()
169
			m.updateViewport()
170
		}
171
	case "home":
172
		m.cursor = 0
173
		m.offset = 0
174
		m.updateViewport()
175
	case "end":
176
		m.cursor = len(m.entries) - 1
177
		m.ensureVisible()
178
		m.updateViewport()
179
	case "pgdown":
180
		h := m.contentHeight()
181
		m.cursor += h - 1
182
		if m.cursor >= len(m.entries) {
183
			m.cursor = len(m.entries) - 1
184
		}
185
		m.ensureVisible()
186
		m.updateViewport()
187
	case "pgup":
188
		h := m.contentHeight()
189
		m.cursor -= h - 1
190
		if m.cursor < 0 {
191
			m.cursor = 0
192
		}
193
		m.ensureVisible()
194
		m.updateViewport()
195
	case "/":
196
		m.searching = true
197
		m.searchInput = ""
198
	case "n":
199
		m.searchNext(1)
200
	case "N":
201
		m.searchNext(-1)
202
	case "escape":
203
		m.searchQuery = ""
204
	}
205
	return m, nil
206
}
207
208
func (m Model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
209
	switch msg.String() {
210
	case "enter":
211
		m.searching = false
212
		m.searchQuery = m.searchInput
213
		if m.searchQuery != "" {
214
			m.searchNext(1)
215
		}
216
	case "escape":
217
		m.searching = false
218
		m.searchInput = ""
219
	case "backspace":
220
		if len(m.searchInput) > 0 {
221
			m.searchInput = m.searchInput[:len(m.searchInput)-1]
222
		}
223
	case "ctrl+u":
224
		m.searchInput = ""
225
	default:
226
		// Only accept printable characters.
227
		s := msg.String()
228
		if len(s) == 1 && s[0] >= 32 && s[0] < 127 || len(s) > 1 {
229
			// Filter out special keys.
230
			if !strings.HasPrefix(s, "ctrl+") && !strings.HasPrefix(s, "alt+") &&
231
				s != "up" && s != "down" && s != "left" && s != "right" &&
232
				s != "tab" && s != "pgup" && s != "pgdown" && s != "home" && s != "end" {
233
				m.searchInput += s
234
			}
235
		}
236
	}
237
	return m, nil
238
}
239
240
func (m *Model) searchNext(dir int) { //nolint:unparam
241
	if m.searchQuery == "" {
242
		return
243
	}
244
	query := strings.ToLower(m.searchQuery)
245
	n := len(m.entries)
246
	for i := 1; i <= n; i++ {
247
		idx := (m.cursor + i*dir%n + n) % n
248
		e := m.entries[idx]
249
		if strings.Contains(strings.ToLower(e.Label), query) ||
250
			strings.Contains(strings.ToLower(e.Content), query) {
251
			m.cursor = idx
252
			m.ensureVisible()
253
			m.updateViewport()
254
			return
255
		}
256
	}
257
}
258
259
func (m Model) updateDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
260
	switch msg.String() {
261
	case "up":
262
		m.viewport.ScrollUp(1)
263
	case "down":
264
		m.viewport.ScrollDown(1)
265
	case "pgup":
266
		m.viewport.HalfPageUp()
267
	case "pgdown":
268
		m.viewport.HalfPageDown()
269
	case "home":
270
		m.viewport.GotoTop()
271
	case "end":
272
		m.viewport.GotoBottom()
273
	}
274
	return m, nil
275
}
276
277
func (m Model) View() string {
278
	if !m.ready || len(m.entries) == 0 {
279
		if len(m.entries) == 0 {
280
			return "No log entries found.\n\nPress q to quit."
281
		}
282
		return "Loading..."
283
	}
284
285
	listTW := m.listTextWidth()
286
	contentH := m.contentHeight()
287
288
	// Build list pane content.
289
	var list strings.Builder
290
	rows := 0
291
	for i := m.offset; i < len(m.entries) && rows < contentH; i++ {
292
		e := m.entries[i]
293
		ts := "        "
294
		if !e.Time.IsZero() {
295
			ts = e.Time.Format("15:04:05")
296
		}
297
		// 1(space) + 8(time) + 1(sp) + 8(kind) + 1(sp) = 19 fixed cols
298
		labelMax := max(listTW-19, 10)
299
		kindTag := fmt.Sprintf("%-8s", e.Kind.String())
300
		if rows > 0 {
301
			list.WriteByte('\n')
302
		}
303
		if i == m.cursor {
304
			line := fmt.Sprintf(" %s %-8s %s", ts, e.Kind.String(), truncate(e.Label, labelMax))
305
			list.WriteString(selectedStyle.Width(listTW).Render(line))
306
		} else {
307
			fmt.Fprintf(&list, " %s %s %s",
308
				timeStyle.Render(ts),
309
				kindStyle(e.Kind).Render(kindTag),
310
				truncate(e.Label, labelMax),
311
			)
312
		}
313
		rows++
314
315
		// Render separator after this entry if flagged and there's room.
316
		if e.SepAfter && rows < contentH {
317
			list.WriteByte('\n')
318
			list.WriteString(sepStyle.Render(strings.Repeat("─", listTW)))
319
			rows++
320
		}
321
	}
322
323
	lStyle := paneStyle
324
	dStyle := paneStyle
325
	if m.focus == focusList {
326
		lStyle = paneActiveStyle
327
	} else {
328
		dStyle = paneActiveStyle
329
	}
330
331
	listLipW := m.listLipWidth()
332
	detailLipW := m.detailLipWidth()
333
334
	listPane := lStyle.
335
		Width(listLipW).
336
		Height(contentH).
337
		Render(list.String())
338
	detailPane := dStyle.
339
		Width(detailLipW).
340
		Height(contentH).
341
		Render(m.viewport.View())
342
343
	body := lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane)
344
345
	title := titleStyle.Render(fmt.Sprintf(" claudio log-reader — %d entries ", len(m.entries)))
346
347
	var bottom string
348
	if m.searching {
349
		bottom = searchStyle.Render("/") + searchInputStyle.Render(m.searchInput) + searchStyle.Render("█")
350
	} else {
351
		focusHint := "list"
352
		if m.focus == focusDetail {
353
			focusHint = "detail"
354
		}
355
		if m.searchQuery != "" {
356
			bottom = helpStyle.Render(fmt.Sprintf(
357
				" [%s] search:%s  n/N:next/prev  esc:clear  tab:pane  ↑↓  pgup/pgdn  q:quit ",
358
				focusHint, m.searchQuery,
359
			))
360
		} else {
361
			bottom = helpStyle.Render(fmt.Sprintf(
362
				" [%s] tab:switch pane  ↑↓:navigate  pgup/pgdn:page  /:search  q:quit ",
363
				focusHint,
364
			))
365
		}
366
	}
367
368
	return lipgloss.JoinVertical(lipgloss.Left, title, body, bottom)
369
}
370
371
var (
372
	headerKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
373
	headerValStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
374
	reqLineStyle   = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214"))
375
	sectionStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true)
376
	thinkingStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Italic(true)
377
	contentStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
378
	labelStyle     = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
379
	valueStyle     = lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Bold(true)
380
)
381
382
func (m *Model) updateViewport() {
383
	if m.cursor >= 0 && m.cursor < len(m.entries) {
384
		e := m.entries[m.cursor]
385
		w := m.detailTextWidth()
386
		var content string
387
		switch {
388
		case e.RequestLine != "":
389
			content = m.formatHTTPEntry(e, w)
390
		case e.Stream != nil:
391
			content = m.formatStreamEntry(e, w)
392
		default:
393
			content = wrapLines(e.Content, w)
394
		}
395
		m.viewport.SetContent(content)
396
		m.viewport.GotoTop()
397
	}
398
}
399
400
func (m Model) formatHTTPEntry(e Entry, maxW int) string {
401
	var b strings.Builder
402
403
	// Request/status line.
404
	b.WriteString(reqLineStyle.Render(truncate(e.RequestLine, maxW)))
405
	b.WriteString("\n\n")
406
407
	// Headers table.
408
	if len(e.Headers) > 0 {
409
		b.WriteString(sectionStyle.Render("Headers"))
410
		b.WriteByte('\n')
411
412
		// Find max key width for alignment.
413
		maxKey := 0
414
		for _, h := range e.Headers {
415
			if len(h.Key) > maxKey {
416
				maxKey = len(h.Key)
417
			}
418
		}
419
		// Cap key column so values still have room.
420
		maxKey = min(maxKey, maxW/2)
421
422
		for _, h := range e.Headers {
423
			key := truncate(h.Key, maxKey)
424
			valW := max(maxW-maxKey-3, 10) // 3 = " : "
425
			val := truncate(h.Value, valW)
426
			fmt.Fprintf(&b, "  %s : %s\n",
427
				headerKeyStyle.Width(maxKey).Render(key),
428
				headerValStyle.Render(val),
429
			)
430
		}
431
	}
432
433
	// Body.
434
	if e.Body != "" {
435
		b.WriteByte('\n')
436
		b.WriteString(sectionStyle.Render("Body"))
437
		b.WriteByte('\n')
438
		body := prettifyBody(e.Body)
439
		colored := colorizeJSON(body)
440
		b.WriteString(ansiWrap(colored, maxW))
441
	}
442
443
	return b.String()
444
}
445
446
func (m Model) formatStreamEntry(e Entry, maxW int) string {
447
	sd := e.Stream
448
	var b strings.Builder
449
450
	// Summary line.
451
	b.WriteString(reqLineStyle.Render("Stream Response"))
452
	b.WriteString("\n\n")
453
454
	// Info table.
455
	b.WriteString(sectionStyle.Render("Info"))
456
	b.WriteByte('\n')
457
	writeKV(&b, "Model", sd.Model, maxW)
458
	writeKV(&b, "Chunks", fmt.Sprintf("%d", sd.Chunks), maxW)
459
	if sd.StopReason != "" {
460
		writeKV(&b, "Stop Reason", sd.StopReason, maxW)
461
	}
462
	if sd.Usage != nil {
463
		writeKV(&b, "Prompt Tokens", fmt.Sprintf("%d", sd.Usage.PromptTokens), maxW)
464
		writeKV(&b, "Completion Tokens", fmt.Sprintf("%d", sd.Usage.CompletionTokens), maxW)
465
		writeKV(&b, "Total Tokens", fmt.Sprintf("%d", sd.Usage.TotalTokens), maxW)
466
	}
467
468
	// Thinking.
469
	if sd.Thinking != "" {
470
		b.WriteByte('\n')
471
		b.WriteString(sectionStyle.Render("Thinking"))
472
		b.WriteByte('\n')
473
		b.WriteString(ansiWrap(thinkingStyle.Render(sd.Thinking), maxW))
474
	}
475
476
	// Content.
477
	if sd.Content != "" {
478
		b.WriteByte('\n')
479
		b.WriteString(sectionStyle.Render("Content"))
480
		b.WriteByte('\n')
481
		b.WriteString(ansiWrap(contentStyle.Render(sd.Content), maxW))
482
	}
483
484
	return b.String()
485
}
486
487
func writeKV(b *strings.Builder, key, val string, maxW int) {
488
	keyW := 20
489
	fmt.Fprintf(b, "  %s %s\n",
490
		labelStyle.Width(keyW).Render(key+":"),
491
		valueStyle.Render(truncate(val, max(maxW-keyW-3, 10))),
492
	)
493
}
494
495
func (m *Model) ensureVisible() {
496
	h := m.contentHeight()
497
	if h <= 0 {
498
		return
499
	}
500
	if m.cursor < m.offset {
501
		m.offset = m.cursor
502
	}
503
	if m.cursor >= m.offset+h {
504
		m.offset = m.cursor - h + 1
505
	}
506
}
507
508
// Layout arithmetic:
509
//
510
// lipgloss.Width(W) renders to outer display width = W + borderCols.
511
// Text columns inside that = W - paddingCols.
512
//
513
// We need: listOuter + detailOuter = termWidth
514
//          i.e. (listLipW + borderCols) + (detailLipW + borderCols) = termWidth
515
516
// listLipWidth is the value passed to lipgloss.Width() for the list pane.
517
func (m Model) listLipWidth() int {
518
	// Target ~40 % of the terminal for the list pane's outer width.
519
	outerTarget := max(m.width*40/100, 30)
520
	return outerTarget - borderCols // lipgloss Width value
521
}
522
523
// detailLipWidth is the value passed to lipgloss.Width() for the detail pane.
524
func (m Model) detailLipWidth() int {
525
	listOuter := m.listLipWidth() + borderCols
526
	remaining := m.width - listOuter
527
	return max(remaining-borderCols, 10) // lipgloss Width value
528
}
529
530
// listTextWidth is the usable text columns inside the list pane.
531
func (m Model) listTextWidth() int {
532
	return max(m.listLipWidth()-paddingCols, 10)
533
}
534
535
// detailTextWidth is the usable text columns inside the detail pane.
536
func (m Model) detailTextWidth() int {
537
	return max(m.detailLipWidth()-paddingCols, 10)
538
}
539
540
// contentHeight is the usable row count inside each pane.
541
func (m Model) contentHeight() int {
542
	// title(1) + border-top(1) + border-bottom(1) + help(1) = 4 rows of chrome
543
	return max(m.height-4, 5)
544
}
545
546
// wrapLines hard-wraps each line to fit within maxW columns.
547
func wrapLines(s string, maxW int) string {
548
	if maxW <= 0 {
549
		return s
550
	}
551
	var b strings.Builder
552
	for _, line := range strings.Split(s, "\n") {
553
		for len(line) > maxW {
554
			b.WriteString(line[:maxW])
555
			b.WriteByte('\n')
556
			line = line[maxW:]
557
		}
558
		b.WriteString(line)
559
		b.WriteByte('\n')
560
	}
561
	return b.String()
562
}
563

Source Files