tui.go

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

Source Files