feat(cli): add Viewport for scrollable content (logs, diffs, docs)

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-21 18:13:37 +00:00
parent a0660e5802
commit 58ca902320
2 changed files with 237 additions and 0 deletions

176
pkg/cli/viewport.go Normal file
View file

@ -0,0 +1,176 @@
package cli
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/term"
)
// viewportModel is the internal bubbletea model for scrollable content.
type viewportModel struct {
title string
lines []string
offset int
height int
quitted bool
}
func newViewportModel(content, title string, height int) *viewportModel {
lines := strings.Split(content, "\n")
return &viewportModel{
title: title,
lines: lines,
height: height,
}
}
func (m *viewportModel) scrollDown() {
maxOffset := len(m.lines) - m.height
if maxOffset < 0 {
maxOffset = 0
}
if m.offset < maxOffset {
m.offset++
}
}
func (m *viewportModel) scrollUp() {
if m.offset > 0 {
m.offset--
}
}
func (m *viewportModel) Init() tea.Cmd {
return nil
}
func (m *viewportModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyUp:
m.scrollUp()
case tea.KeyDown:
m.scrollDown()
case tea.KeyPgUp:
for i := 0; i < m.height; i++ {
m.scrollUp()
}
case tea.KeyPgDown:
for i := 0; i < m.height; i++ {
m.scrollDown()
}
case tea.KeyHome:
m.offset = 0
case tea.KeyEnd:
maxOffset := len(m.lines) - m.height
if maxOffset < 0 {
maxOffset = 0
}
m.offset = maxOffset
case tea.KeyEscape, tea.KeyCtrlC:
m.quitted = true
return m, tea.Quit
case tea.KeyRunes:
switch string(msg.Runes) {
case "q":
m.quitted = true
return m, tea.Quit
case "j":
m.scrollDown()
case "k":
m.scrollUp()
case "g":
m.offset = 0
case "G":
maxOffset := len(m.lines) - m.height
if maxOffset < 0 {
maxOffset = 0
}
m.offset = maxOffset
}
}
}
return m, nil
}
func (m *viewportModel) View() string {
var sb strings.Builder
if m.title != "" {
sb.WriteString(BoldStyle.Render(m.title) + "\n")
sb.WriteString(DimStyle.Render(strings.Repeat("\u2500", len(m.title))) + "\n")
}
// Visible window
end := m.offset + m.height
if end > len(m.lines) {
end = len(m.lines)
}
for _, line := range m.lines[m.offset:end] {
sb.WriteString(line + "\n")
}
// Scroll indicator
total := len(m.lines)
if total > m.height {
pct := (m.offset * 100) / (total - m.height)
sb.WriteString(DimStyle.Render(fmt.Sprintf("\n%d%% (%d/%d lines)", pct, m.offset+m.height, total)))
}
sb.WriteString("\n" + DimStyle.Render("\u2191/\u2193 scroll \u2022 PgUp/PgDn page \u2022 q quit"))
return sb.String()
}
// ViewportOption configures Viewport behaviour.
type ViewportOption func(*viewportConfig)
type viewportConfig struct {
title string
height int
}
// WithViewportTitle sets the title shown above the viewport.
func WithViewportTitle(title string) ViewportOption {
return func(c *viewportConfig) {
c.title = title
}
}
// WithViewportHeight sets the visible height in lines.
func WithViewportHeight(n int) ViewportOption {
return func(c *viewportConfig) {
c.height = n
}
}
// Viewport displays scrollable content in the terminal.
// Falls back to printing the full content when stdin is not a terminal.
//
// cli.Viewport(longContent, WithViewportTitle("Build Log"), WithViewportHeight(20))
func Viewport(content string, opts ...ViewportOption) error {
cfg := &viewportConfig{
height: 20,
}
for _, opt := range opts {
opt(cfg)
}
// Fall back to plain output if not a terminal
if !term.IsTerminal(0) {
if cfg.title != "" {
fmt.Println(BoldStyle.Render(cfg.title))
fmt.Println(DimStyle.Render(strings.Repeat("\u2500", len(cfg.title))))
}
fmt.Println(content)
return nil
}
m := newViewportModel(content, cfg.title, cfg.height)
p := tea.NewProgram(m)
_, err := p.Run()
return err
}

61
pkg/cli/viewport_test.go Normal file
View file

@ -0,0 +1,61 @@
package cli
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestViewportModel_Good_Create(t *testing.T) {
content := "line 1\nline 2\nline 3"
m := newViewportModel(content, "Title", 5)
assert.Equal(t, "Title", m.title)
assert.Equal(t, 3, len(m.lines))
assert.Equal(t, 0, m.offset)
}
func TestViewportModel_Good_ScrollDown(t *testing.T) {
lines := make([]string, 20)
for i := range lines {
lines[i] = strings.Repeat("x", 10)
}
m := newViewportModel(strings.Join(lines, "\n"), "", 5)
m.scrollDown()
assert.Equal(t, 1, m.offset)
}
func TestViewportModel_Good_ScrollUp(t *testing.T) {
lines := make([]string, 20)
for i := range lines {
lines[i] = strings.Repeat("x", 10)
}
m := newViewportModel(strings.Join(lines, "\n"), "", 5)
m.scrollDown()
m.scrollDown()
m.scrollUp()
assert.Equal(t, 1, m.offset)
}
func TestViewportModel_Good_NoScrollPastTop(t *testing.T) {
m := newViewportModel("a\nb\nc", "", 5)
m.scrollUp() // Already at top
assert.Equal(t, 0, m.offset)
}
func TestViewportModel_Good_NoScrollPastBottom(t *testing.T) {
m := newViewportModel("a\nb\nc", "", 5)
for i := 0; i < 10; i++ {
m.scrollDown()
}
// Should clamp -- can't scroll past content
assert.GreaterOrEqual(t, m.offset, 0)
}
func TestViewportModel_Good_View(t *testing.T) {
m := newViewportModel("line 1\nline 2", "My Title", 10)
view := m.View()
assert.Contains(t, view, "My Title")
assert.Contains(t, view, "line 1")
assert.Contains(t, view, "line 2")
}