cli/pkg/cli/frame.go
Snider 76cd3a5306
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 13s
refactor: code quality improvements from Gemini review
- Split frame.go: extract built-in components to frame_components.go
- Replace custom indexOf with strings.Index in frame_test.go
- Make prompt.go testable: accept io.Reader via SetStdin, add tests
- Decompose runGoQA: extract emitQAJSON and emitQASummary helpers
- DRY: centralise loadConfig into cmd/config/cmd.go
- Remove hardcoded MACOSX_DEPLOYMENT_TARGET from test/fuzz/cov commands
- Add error assertions to coverage_test.go

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:29:44 +00:00

470 lines
11 KiB
Go

package cli
import (
"fmt"
"io"
"os"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"golang.org/x/term"
)
// Model is the interface for components that slot into Frame regions.
// View receives the allocated width and height and returns rendered text.
type Model interface {
View(width, height int) string
}
// ModelFunc is a convenience adapter for using a function as a Model.
type ModelFunc func(width, height int) string
// View implements Model.
func (f ModelFunc) View(width, height int) string { return f(width, height) }
// Frame is a live compositional AppShell for TUI.
// Uses HLCRF variant strings for region layout — same as the static Layout system,
// but with live-updating Model components instead of static strings.
//
// frame := cli.NewFrame("HCF")
// frame.Header(cli.StatusLine("core dev", "18 repos", "main"))
// frame.Content(myTableModel)
// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit"))
// frame.Run()
type Frame struct {
variant string
layout *Composite
models map[Region]Model
history []Model // content region stack for Navigate/Back
out io.Writer
done chan struct{}
mu sync.Mutex
// Focus management (bubbletea upgrade)
focused Region
keyMap KeyMap
width int
height int
program *tea.Program
}
// NewFrame creates a new Frame with the given HLCRF variant string.
//
// frame := cli.NewFrame("HCF") // header, content, footer
// frame := cli.NewFrame("H[LC]F") // header, [left + content], footer
func NewFrame(variant string) *Frame {
return &Frame{
variant: variant,
layout: Layout(variant),
models: make(map[Region]Model),
out: os.Stdout,
done: make(chan struct{}),
focused: RegionContent,
keyMap: DefaultKeyMap(),
width: 80,
height: 24,
}
}
// Header sets the Header region model.
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
// Left sets the Left sidebar region model.
func (f *Frame) Left(m Model) *Frame { f.setModel(RegionLeft, m); return f }
// Content sets the Content region model.
func (f *Frame) Content(m Model) *Frame { f.setModel(RegionContent, m); return f }
// Right sets the Right sidebar region model.
func (f *Frame) Right(m Model) *Frame { f.setModel(RegionRight, m); return f }
// Footer sets the Footer region model.
func (f *Frame) Footer(m Model) *Frame { f.setModel(RegionFooter, m); return f }
func (f *Frame) setModel(r Region, m Model) {
f.mu.Lock()
defer f.mu.Unlock()
f.models[r] = m
}
// Navigate replaces the Content region with a new model, pushing the current one
// onto the history stack for Back().
func (f *Frame) Navigate(m Model) {
f.mu.Lock()
defer f.mu.Unlock()
if current, ok := f.models[RegionContent]; ok {
f.history = append(f.history, current)
}
f.models[RegionContent] = m
}
// Back pops the content history stack, restoring the previous Content model.
// Returns false if the history is empty.
func (f *Frame) Back() bool {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.history) == 0 {
return false
}
f.models[RegionContent] = f.history[len(f.history)-1]
f.history = f.history[:len(f.history)-1]
return true
}
// Stop signals the Frame to exit its Run loop.
func (f *Frame) Stop() {
if f.program != nil {
f.program.Quit()
return
}
select {
case <-f.done:
default:
close(f.done)
}
}
// Send injects a message into the Frame's tea.Program.
// Safe to call before Run() (message is discarded).
func (f *Frame) Send(msg tea.Msg) {
if f.program != nil {
f.program.Send(msg)
}
}
// WithKeyMap sets custom key bindings for Frame navigation.
func (f *Frame) WithKeyMap(km KeyMap) *Frame {
f.keyMap = km
return f
}
// Focused returns the currently focused region.
func (f *Frame) Focused() Region {
f.mu.Lock()
defer f.mu.Unlock()
return f.focused
}
// Focus sets focus to a specific region.
// Ignores the request if the region is not in this Frame's variant.
func (f *Frame) Focus(r Region) {
f.mu.Lock()
defer f.mu.Unlock()
if _, exists := f.layout.regions[r]; exists {
f.focused = r
}
}
// buildFocusRing returns the ordered list of regions in this Frame's variant.
// Order follows HLCRF convention.
func (f *Frame) buildFocusRing() []Region {
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
var ring []Region
for _, r := range order {
if _, exists := f.layout.regions[r]; exists {
ring = append(ring, r)
}
}
return ring
}
// Init implements tea.Model. Collects Init() from all FrameModel regions.
func (f *Frame) Init() tea.Cmd {
f.mu.Lock()
defer f.mu.Unlock()
var cmds []tea.Cmd
for _, m := range f.models {
fm := adaptModel(m)
if cmd := fm.Init(); cmd != nil {
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
// Update implements tea.Model. Routes messages based on type and focus.
func (f *Frame) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
f.mu.Lock()
defer f.mu.Unlock()
switch msg := msg.(type) {
case tea.WindowSizeMsg:
f.width = msg.Width
f.height = msg.Height
return f, f.broadcastLocked(msg)
case tea.KeyMsg:
switch msg.Type {
case f.keyMap.Quit:
return f, tea.Quit
case f.keyMap.Back:
f.backLocked()
return f, nil
case f.keyMap.FocusNext:
f.cycleFocusLocked(1)
return f, nil
case f.keyMap.FocusPrev:
f.cycleFocusLocked(-1)
return f, nil
case f.keyMap.FocusUp:
f.spatialFocusLocked(RegionHeader)
return f, nil
case f.keyMap.FocusDown:
f.spatialFocusLocked(RegionFooter)
return f, nil
case f.keyMap.FocusLeft:
f.spatialFocusLocked(RegionLeft)
return f, nil
case f.keyMap.FocusRight:
f.spatialFocusLocked(RegionRight)
return f, nil
default:
// Forward to focused region
return f, f.updateFocusedLocked(msg)
}
default:
// Broadcast non-key messages to all regions
return f, f.broadcastLocked(msg)
}
}
// View implements tea.Model. Composes region views using lipgloss.
func (f *Frame) View() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.viewLocked()
}
func (f *Frame) viewLocked() string {
w, h := f.width, f.height
if w == 0 || h == 0 {
w, h = f.termSize()
}
// Calculate region dimensions
headerH, footerH := 0, 0
if _, ok := f.layout.regions[RegionHeader]; ok {
if _, ok := f.models[RegionHeader]; ok {
headerH = 1
}
}
if _, ok := f.layout.regions[RegionFooter]; ok {
if _, ok := f.models[RegionFooter]; ok {
footerH = 1
}
}
middleH := max(h-headerH-footerH, 1)
// Render each region
header := f.renderRegionLocked(RegionHeader, w, headerH)
footer := f.renderRegionLocked(RegionFooter, w, footerH)
// Calculate sidebar widths
leftW, rightW := 0, 0
if _, ok := f.layout.regions[RegionLeft]; ok {
if _, ok := f.models[RegionLeft]; ok {
leftW = w / 4
}
}
if _, ok := f.layout.regions[RegionRight]; ok {
if _, ok := f.models[RegionRight]; ok {
rightW = w / 4
}
}
contentW := max(w-leftW-rightW, 1)
left := f.renderRegionLocked(RegionLeft, leftW, middleH)
right := f.renderRegionLocked(RegionRight, rightW, middleH)
content := f.renderRegionLocked(RegionContent, contentW, middleH)
// Compose middle row
var middleParts []string
if leftW > 0 {
middleParts = append(middleParts, left)
}
middleParts = append(middleParts, content)
if rightW > 0 {
middleParts = append(middleParts, right)
}
middle := content
if len(middleParts) > 1 {
middle = lipgloss.JoinHorizontal(lipgloss.Top, middleParts...)
}
// Compose full layout
var verticalParts []string
if headerH > 0 {
verticalParts = append(verticalParts, header)
}
verticalParts = append(verticalParts, middle)
if footerH > 0 {
verticalParts = append(verticalParts, footer)
}
return lipgloss.JoinVertical(lipgloss.Left, verticalParts...)
}
func (f *Frame) renderRegionLocked(r Region, w, h int) string {
if w <= 0 || h <= 0 {
return ""
}
m, ok := f.models[r]
if !ok {
return ""
}
fm := adaptModel(m)
return fm.View(w, h)
}
// cycleFocusLocked moves focus forward (+1) or backward (-1) in the focus ring.
// Must be called with f.mu held.
func (f *Frame) cycleFocusLocked(dir int) {
ring := f.buildFocusRing()
if len(ring) == 0 {
return
}
idx := 0
for i, r := range ring {
if r == f.focused {
idx = i
break
}
}
idx = (idx + dir + len(ring)) % len(ring)
f.focused = ring[idx]
}
// spatialFocusLocked moves focus to a specific region if it exists in the layout.
// Must be called with f.mu held.
func (f *Frame) spatialFocusLocked(target Region) {
if _, exists := f.layout.regions[target]; exists {
f.focused = target
}
}
// backLocked pops the content history. Must be called with f.mu held.
func (f *Frame) backLocked() {
if len(f.history) == 0 {
return
}
f.models[RegionContent] = f.history[len(f.history)-1]
f.history = f.history[:len(f.history)-1]
}
// broadcastLocked sends a message to all FrameModel regions.
// Must be called with f.mu held.
func (f *Frame) broadcastLocked(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
for r, m := range f.models {
fm := adaptModel(m)
updated, cmd := fm.Update(msg)
f.models[r] = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
// updateFocusedLocked sends a message to only the focused region.
// Must be called with f.mu held.
func (f *Frame) updateFocusedLocked(msg tea.Msg) tea.Cmd {
m, ok := f.models[f.focused]
if !ok {
return nil
}
fm := adaptModel(m)
updated, cmd := fm.Update(msg)
f.models[f.focused] = updated
return cmd
}
// Run renders the frame and blocks. In TTY mode, it live-refreshes at ~12fps.
// In non-TTY mode, it renders once and returns immediately.
func (f *Frame) Run() {
if !f.isTTY() {
fmt.Fprint(f.out, f.String())
return
}
f.runLive()
}
// RunFor runs the frame for a fixed duration, then stops.
// Useful for dashboards that refresh periodically.
func (f *Frame) RunFor(d time.Duration) {
go func() {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
f.Stop()
case <-f.done:
}
}()
f.Run()
}
// String renders the frame as a static string (no ANSI, no live updates).
// This is the non-TTY fallback path.
func (f *Frame) String() string {
f.mu.Lock()
defer f.mu.Unlock()
view := f.viewLocked()
if view == "" {
return ""
}
// Ensure trailing newline for non-TTY consistency
if !strings.HasSuffix(view, "\n") {
view += "\n"
}
return view
}
func (f *Frame) isTTY() bool {
if file, ok := f.out.(*os.File); ok {
return term.IsTerminal(int(file.Fd()))
}
return false
}
func (f *Frame) termSize() (int, int) {
if file, ok := f.out.(*os.File); ok {
w, h, err := term.GetSize(int(file.Fd()))
if err == nil {
return w, h
}
}
return 80, 24 // sensible default
}
func (f *Frame) runLive() {
opts := []tea.ProgramOption{
tea.WithAltScreen(),
}
if f.out != os.Stdout {
opts = append(opts, tea.WithOutput(f.out))
}
p := tea.NewProgram(f, opts...)
f.program = p
if _, err := p.Run(); err != nil {
Error(err.Error())
}
}