Files
gastown/internal/tui/feed/model.go
Steve Yegge c7da650f94 feat: Add charmbracelet TUI for gt feed (gt-u7dxq)
- Add bubbletea and bubbles dependencies
- Create internal/tui/feed package with:
  - model.go: Main bubbletea model with agent tree and event stream
  - view.go: Rendering logic with lipgloss styling
  - keys.go: Vim-style key bindings (j/k, tab, /, q)
  - styles.go: Color palette and component styles
  - events.go: Event source from bd activity
- Update gt feed to use TUI by default (--plain for text mode)
- TUI features: agent tree by role, event stream, keyboard nav

Closes gt-be0as, gt-lexye, gt-1uhmj

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 16:24:28 -08:00

299 lines
6.2 KiB
Go

package feed
import (
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
// Panel represents which panel has focus
type Panel int
const (
PanelTree Panel = iota
PanelFeed
)
// Event represents an activity event
type Event struct {
Time time.Time
Type string // create, update, complete, fail, delete
Actor string // who did it (e.g., "gastown/crew/joe")
Target string // what was affected (e.g., "gt-xyz")
Message string // human-readable description
Rig string // which rig
Role string // actor's role
Raw string // raw line for fallback display
}
// Agent represents an agent in the tree
type Agent struct {
ID string
Name string
Role string // mayor, witness, refinery, crew, polecat
Rig string
Status string // running, idle, working, dead
LastEvent *Event
LastUpdate time.Time
Expanded bool
}
// Rig represents a rig with its agents
type Rig struct {
Name string
Agents map[string]*Agent // keyed by role/name
Expanded bool
}
// Model is the main bubbletea model for the feed TUI
type Model struct {
// Dimensions
width int
height int
// Panels
focusedPanel Panel
treeViewport viewport.Model
feedViewport viewport.Model
// Data
rigs map[string]*Rig
events []Event
// UI state
keys KeyMap
help help.Model
showHelp bool
filter string
filterActive bool
err error
// Event source
eventChan <-chan Event
done chan struct{}
}
// NewModel creates a new feed TUI model
func NewModel() Model {
h := help.New()
h.ShowAll = false
return Model{
focusedPanel: PanelTree,
treeViewport: viewport.New(0, 0),
feedViewport: viewport.New(0, 0),
rigs: make(map[string]*Rig),
events: make([]Event, 0, 1000),
keys: DefaultKeyMap(),
help: h,
done: make(chan struct{}),
}
}
// Init initializes the model
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.listenForEvents(),
tea.SetWindowTitle("GT Feed"),
)
}
// eventMsg is sent when a new event arrives
type eventMsg Event
// tickMsg is sent periodically to refresh the view
type tickMsg time.Time
// listenForEvents returns a command that listens for events
func (m Model) listenForEvents() tea.Cmd {
if m.eventChan == nil {
return nil
}
return func() tea.Msg {
select {
case event, ok := <-m.eventChan:
if !ok {
return nil
}
return eventMsg(event)
case <-m.done:
return nil
}
}
}
// tick returns a command for periodic refresh
func tick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// Update handles messages
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.updateViewportSizes()
case eventMsg:
m.addEvent(Event(msg))
cmds = append(cmds, m.listenForEvents())
case tickMsg:
cmds = append(cmds, tick())
}
// Update viewports
var cmd tea.Cmd
if m.focusedPanel == PanelTree {
m.treeViewport, cmd = m.treeViewport.Update(msg)
} else {
m.feedViewport, cmd = m.feedViewport.Update(msg)
}
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// handleKey processes key presses
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Quit):
close(m.done)
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.showHelp = !m.showHelp
m.help.ShowAll = m.showHelp
return m, nil
case key.Matches(msg, m.keys.Tab):
if m.focusedPanel == PanelTree {
m.focusedPanel = PanelFeed
} else {
m.focusedPanel = PanelTree
}
return m, nil
case key.Matches(msg, m.keys.FocusTree):
m.focusedPanel = PanelTree
return m, nil
case key.Matches(msg, m.keys.FocusFeed):
m.focusedPanel = PanelFeed
return m, nil
case key.Matches(msg, m.keys.Refresh):
m.updateViewContent()
return m, nil
}
// Pass to focused viewport
var cmd tea.Cmd
if m.focusedPanel == PanelTree {
m.treeViewport, cmd = m.treeViewport.Update(msg)
} else {
m.feedViewport, cmd = m.feedViewport.Update(msg)
}
return m, cmd
}
// updateViewportSizes recalculates viewport dimensions
func (m *Model) updateViewportSizes() {
// Reserve space: header (1) + borders (4) + status bar (1) + help (1-2)
headerHeight := 1
statusHeight := 1
helpHeight := 1
if m.showHelp {
helpHeight = 3
}
borderHeight := 4 // top and bottom borders for both panels
availableHeight := m.height - headerHeight - statusHeight - helpHeight - borderHeight
if availableHeight < 4 {
availableHeight = 4
}
// Split 40% tree, 60% feed
treeHeight := availableHeight * 40 / 100
feedHeight := availableHeight - treeHeight
contentWidth := m.width - 4 // borders and padding
if contentWidth < 20 {
contentWidth = 20
}
m.treeViewport.Width = contentWidth
m.treeViewport.Height = treeHeight
m.feedViewport.Width = contentWidth
m.feedViewport.Height = feedHeight
m.updateViewContent()
}
// updateViewContent refreshes the content of both viewports
func (m *Model) updateViewContent() {
m.treeViewport.SetContent(m.renderTree())
m.feedViewport.SetContent(m.renderFeed())
}
// addEvent adds an event and updates the agent tree
func (m *Model) addEvent(e Event) {
m.events = append(m.events, e)
// Keep max 1000 events
if len(m.events) > 1000 {
m.events = m.events[len(m.events)-1000:]
}
// Update agent tree
if e.Rig != "" {
rig, ok := m.rigs[e.Rig]
if !ok {
rig = &Rig{
Name: e.Rig,
Agents: make(map[string]*Agent),
Expanded: true,
}
m.rigs[e.Rig] = rig
}
if e.Actor != "" {
agent, ok := rig.Agents[e.Actor]
if !ok {
agent = &Agent{
ID: e.Actor,
Name: e.Actor,
Role: e.Role,
Rig: e.Rig,
}
rig.Agents[e.Actor] = agent
}
agent.LastEvent = &e
agent.LastUpdate = e.Time
}
}
m.updateViewContent()
}
// SetEventChannel sets the channel to receive events from
func (m *Model) SetEventChannel(ch <-chan Event) {
m.eventChan = ch
}
// View renders the TUI
func (m Model) View() string {
return m.render()
}