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>
This commit is contained in:
342
internal/tui/feed/events.go
Normal file
342
internal/tui/feed/events.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventSource represents a source of events
|
||||
type EventSource interface {
|
||||
Events() <-chan Event
|
||||
Close() error
|
||||
}
|
||||
|
||||
// BdActivitySource reads events from bd activity --follow
|
||||
type BdActivitySource struct {
|
||||
cmd *exec.Cmd
|
||||
events chan Event
|
||||
cancel context.CancelFunc
|
||||
workDir string
|
||||
}
|
||||
|
||||
// NewBdActivitySource creates a new source that tails bd activity
|
||||
func NewBdActivitySource(workDir string) (*BdActivitySource, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
cmd := exec.CommandContext(ctx, "bd", "activity", "--follow")
|
||||
cmd.Dir = workDir
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
source := &BdActivitySource{
|
||||
cmd: cmd,
|
||||
events: make(chan Event, 100),
|
||||
cancel: cancel,
|
||||
workDir: workDir,
|
||||
}
|
||||
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if event := parseBdActivityLine(line); event != nil {
|
||||
select {
|
||||
case source.events <- *event:
|
||||
default:
|
||||
// Drop event if channel full
|
||||
}
|
||||
}
|
||||
}
|
||||
close(source.events)
|
||||
}()
|
||||
|
||||
return source, nil
|
||||
}
|
||||
|
||||
// Events returns the event channel
|
||||
func (s *BdActivitySource) Events() <-chan Event {
|
||||
return s.events
|
||||
}
|
||||
|
||||
// Close stops the source
|
||||
func (s *BdActivitySource) Close() error {
|
||||
s.cancel()
|
||||
return s.cmd.Wait()
|
||||
}
|
||||
|
||||
// bd activity line pattern: [HH:MM:SS] SYMBOL BEAD_ID action · description
|
||||
var bdActivityPattern = regexp.MustCompile(`^\[(\d{2}:\d{2}:\d{2})\]\s+([+→✓✗⊘📌])\s+(\S+)?\s*(\w+)?\s*·?\s*(.*)$`)
|
||||
|
||||
// parseBdActivityLine parses a line from bd activity output
|
||||
func parseBdActivityLine(line string) *Event {
|
||||
matches := bdActivityPattern.FindStringSubmatch(line)
|
||||
if matches == nil {
|
||||
// Try simpler pattern
|
||||
return parseSimpleLine(line)
|
||||
}
|
||||
|
||||
timeStr := matches[1]
|
||||
symbol := matches[2]
|
||||
beadID := matches[3]
|
||||
action := matches[4]
|
||||
message := matches[5]
|
||||
|
||||
// Parse time (assume today)
|
||||
now := time.Now()
|
||||
t, err := time.Parse("15:04:05", timeStr)
|
||||
if err != nil {
|
||||
t = now
|
||||
} else {
|
||||
t = time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), t.Second(), 0, now.Location())
|
||||
}
|
||||
|
||||
// Map symbol to event type
|
||||
eventType := "update"
|
||||
switch symbol {
|
||||
case "+":
|
||||
eventType = "create"
|
||||
case "→":
|
||||
eventType = "update"
|
||||
case "✓":
|
||||
eventType = "complete"
|
||||
case "✗":
|
||||
eventType = "fail"
|
||||
case "⊘":
|
||||
eventType = "delete"
|
||||
case "📌":
|
||||
eventType = "pin"
|
||||
}
|
||||
|
||||
// Try to extract actor and rig from bead ID
|
||||
actor, rig, role := parseBeadContext(beadID)
|
||||
|
||||
return &Event{
|
||||
Time: t,
|
||||
Type: eventType,
|
||||
Actor: actor,
|
||||
Target: beadID,
|
||||
Message: strings.TrimSpace(action + " " + message),
|
||||
Rig: rig,
|
||||
Role: role,
|
||||
Raw: line,
|
||||
}
|
||||
}
|
||||
|
||||
// parseSimpleLine handles lines that don't match the full pattern
|
||||
func parseSimpleLine(line string) *Event {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to extract timestamp
|
||||
var t time.Time
|
||||
if len(line) > 10 && line[0] == '[' {
|
||||
if idx := strings.Index(line, "]"); idx > 0 {
|
||||
timeStr := line[1:idx]
|
||||
now := time.Now()
|
||||
if parsed, err := time.Parse("15:04:05", timeStr); err == nil {
|
||||
t = time.Date(now.Year(), now.Month(), now.Day(),
|
||||
parsed.Hour(), parsed.Minute(), parsed.Second(), 0, now.Location())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.IsZero() {
|
||||
t = time.Now()
|
||||
}
|
||||
|
||||
return &Event{
|
||||
Time: t,
|
||||
Type: "update",
|
||||
Message: line,
|
||||
Raw: line,
|
||||
}
|
||||
}
|
||||
|
||||
// parseBeadContext extracts actor/rig/role from a bead ID
|
||||
func parseBeadContext(beadID string) (actor, rig, role string) {
|
||||
if beadID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Agent beads: gt-crew-gastown-joe, gt-witness-gastown, gt-mayor
|
||||
if strings.HasPrefix(beadID, "gt-crew-") {
|
||||
parts := strings.Split(beadID, "-")
|
||||
if len(parts) >= 4 {
|
||||
rig = parts[2]
|
||||
actor = strings.Join(parts[2:], "/")
|
||||
role = "crew"
|
||||
}
|
||||
} else if strings.HasPrefix(beadID, "gt-witness-") {
|
||||
parts := strings.Split(beadID, "-")
|
||||
if len(parts) >= 3 {
|
||||
rig = parts[2]
|
||||
actor = "witness"
|
||||
role = "witness"
|
||||
}
|
||||
} else if strings.HasPrefix(beadID, "gt-refinery-") {
|
||||
parts := strings.Split(beadID, "-")
|
||||
if len(parts) >= 3 {
|
||||
rig = parts[2]
|
||||
actor = "refinery"
|
||||
role = "refinery"
|
||||
}
|
||||
} else if beadID == "gt-mayor" {
|
||||
actor = "mayor"
|
||||
role = "mayor"
|
||||
} else if beadID == "gt-deacon" {
|
||||
actor = "deacon"
|
||||
role = "deacon"
|
||||
} else if strings.HasPrefix(beadID, "gt-polecat-") {
|
||||
parts := strings.Split(beadID, "-")
|
||||
if len(parts) >= 3 {
|
||||
rig = parts[2]
|
||||
actor = strings.Join(parts[2:], "-")
|
||||
role = "polecat"
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// JSONLSource reads events from a JSONL file (like .events.jsonl)
|
||||
type JSONLSource struct {
|
||||
file *os.File
|
||||
events chan Event
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// JSONLEvent is the structure of events in .events.jsonl
|
||||
type JSONLEvent struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Type string `json:"type"`
|
||||
Actor string `json:"actor"`
|
||||
Target string `json:"target"`
|
||||
Message string `json:"message"`
|
||||
Rig string `json:"rig"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// NewJSONLSource creates a source that tails a JSONL file
|
||||
func NewJSONLSource(filePath string) (*JSONLSource, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
source := &JSONLSource{
|
||||
file: file,
|
||||
events: make(chan Event, 100),
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
go source.tail(ctx)
|
||||
|
||||
return source, nil
|
||||
}
|
||||
|
||||
// tail follows the file and sends events
|
||||
func (s *JSONLSource) tail(ctx context.Context) {
|
||||
defer close(s.events)
|
||||
|
||||
// Seek to end for live tailing
|
||||
s.file.Seek(0, 2)
|
||||
|
||||
scanner := bufio.NewScanner(s.file)
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if event := parseJSONLLine(line); event != nil {
|
||||
select {
|
||||
case s.events <- *event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Events returns the event channel
|
||||
func (s *JSONLSource) Events() <-chan Event {
|
||||
return s.events
|
||||
}
|
||||
|
||||
// Close stops the source
|
||||
func (s *JSONLSource) Close() error {
|
||||
s.cancel()
|
||||
return s.file.Close()
|
||||
}
|
||||
|
||||
// parseJSONLLine parses a JSONL event line
|
||||
func parseJSONLLine(line string) *Event {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var je JSONLEvent
|
||||
if err := json.Unmarshal([]byte(line), &je); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
t, err := time.Parse(time.RFC3339, je.Timestamp)
|
||||
if err != nil {
|
||||
t = time.Now()
|
||||
}
|
||||
|
||||
return &Event{
|
||||
Time: t,
|
||||
Type: je.Type,
|
||||
Actor: je.Actor,
|
||||
Target: je.Target,
|
||||
Message: je.Message,
|
||||
Rig: je.Rig,
|
||||
Role: je.Role,
|
||||
Raw: line,
|
||||
}
|
||||
}
|
||||
|
||||
// FindBeadsDir finds the beads directory for the given working directory
|
||||
func FindBeadsDir(workDir string) (string, error) {
|
||||
// Walk up looking for .beads
|
||||
dir := workDir
|
||||
for {
|
||||
beadsPath := filepath.Join(dir, ".beads")
|
||||
if info, err := os.Stat(beadsPath); err == nil && info.IsDir() {
|
||||
return beadsPath, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
127
internal/tui/feed/keys.go
Normal file
127
internal/tui/feed/keys.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package feed
|
||||
|
||||
import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
// KeyMap defines the key bindings for the feed TUI.
|
||||
type KeyMap struct {
|
||||
// Navigation
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
PageUp key.Binding
|
||||
PageDown key.Binding
|
||||
Top key.Binding
|
||||
Bottom key.Binding
|
||||
|
||||
// Panel switching
|
||||
Tab key.Binding
|
||||
ShiftTab key.Binding
|
||||
FocusTree key.Binding
|
||||
FocusFeed key.Binding
|
||||
|
||||
// Actions
|
||||
Enter key.Binding
|
||||
Expand key.Binding
|
||||
Refresh key.Binding
|
||||
|
||||
// Search/Filter
|
||||
Search key.Binding
|
||||
Filter key.Binding
|
||||
ClearFilter key.Binding
|
||||
|
||||
// General
|
||||
Help key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
// DefaultKeyMap returns the default key bindings.
|
||||
func DefaultKeyMap() KeyMap {
|
||||
return KeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "k"),
|
||||
key.WithHelp("↑/k", "up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "j"),
|
||||
key.WithHelp("↓/j", "down"),
|
||||
),
|
||||
PageUp: key.NewBinding(
|
||||
key.WithKeys("pgup", "ctrl+u"),
|
||||
key.WithHelp("pgup", "page up"),
|
||||
),
|
||||
PageDown: key.NewBinding(
|
||||
key.WithKeys("pgdown", "ctrl+d"),
|
||||
key.WithHelp("pgdn", "page down"),
|
||||
),
|
||||
Top: key.NewBinding(
|
||||
key.WithKeys("home", "g"),
|
||||
key.WithHelp("g", "top"),
|
||||
),
|
||||
Bottom: key.NewBinding(
|
||||
key.WithKeys("end", "G"),
|
||||
key.WithHelp("G", "bottom"),
|
||||
),
|
||||
Tab: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "switch panel"),
|
||||
),
|
||||
ShiftTab: key.NewBinding(
|
||||
key.WithKeys("shift+tab"),
|
||||
key.WithHelp("S-tab", "prev panel"),
|
||||
),
|
||||
FocusTree: key.NewBinding(
|
||||
key.WithKeys("1"),
|
||||
key.WithHelp("1", "agent tree"),
|
||||
),
|
||||
FocusFeed: key.NewBinding(
|
||||
key.WithKeys("2"),
|
||||
key.WithHelp("2", "event feed"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "expand/details"),
|
||||
),
|
||||
Expand: key.NewBinding(
|
||||
key.WithKeys("o", "l"),
|
||||
key.WithHelp("o", "toggle expand"),
|
||||
),
|
||||
Refresh: key.NewBinding(
|
||||
key.WithKeys("r"),
|
||||
key.WithHelp("r", "refresh"),
|
||||
),
|
||||
Search: key.NewBinding(
|
||||
key.WithKeys("/"),
|
||||
key.WithHelp("/", "search"),
|
||||
),
|
||||
Filter: key.NewBinding(
|
||||
key.WithKeys("f"),
|
||||
key.WithHelp("f", "filter"),
|
||||
),
|
||||
ClearFilter: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "clear"),
|
||||
),
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("?"),
|
||||
key.WithHelp("?", "help"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("q", "ctrl+c"),
|
||||
key.WithHelp("q", "quit"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ShortHelp returns key bindings for the short help view.
|
||||
func (k KeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Up, k.Down, k.Tab, k.Search, k.Filter, k.Quit, k.Help}
|
||||
}
|
||||
|
||||
// FullHelp returns key bindings for the full help view.
|
||||
func (k KeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.Up, k.Down, k.PageUp, k.PageDown, k.Top, k.Bottom},
|
||||
{k.Tab, k.FocusTree, k.FocusFeed, k.Enter, k.Expand},
|
||||
{k.Search, k.Filter, k.ClearFilter, k.Refresh},
|
||||
{k.Help, k.Quit},
|
||||
}
|
||||
}
|
||||
298
internal/tui/feed/model.go
Normal file
298
internal/tui/feed/model.go
Normal file
@@ -0,0 +1,298 @@
|
||||
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()
|
||||
}
|
||||
118
internal/tui/feed/styles.go
Normal file
118
internal/tui/feed/styles.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Package feed provides a TUI for the Gas Town activity feed.
|
||||
package feed
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Color palette
|
||||
var (
|
||||
colorPrimary = lipgloss.Color("12") // Blue
|
||||
colorSuccess = lipgloss.Color("10") // Green
|
||||
colorWarning = lipgloss.Color("11") // Yellow
|
||||
colorError = lipgloss.Color("9") // Red
|
||||
colorDim = lipgloss.Color("8") // Gray
|
||||
colorHighlight = lipgloss.Color("14") // Cyan
|
||||
colorAccent = lipgloss.Color("13") // Magenta
|
||||
)
|
||||
|
||||
// Styles for the feed TUI
|
||||
var (
|
||||
// Header styles
|
||||
HeaderStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorPrimary).
|
||||
Padding(0, 1)
|
||||
|
||||
TitleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("15"))
|
||||
|
||||
FilterStyle = lipgloss.NewStyle().
|
||||
Foreground(colorDim)
|
||||
|
||||
// Agent tree styles
|
||||
TreePanelStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorDim).
|
||||
Padding(0, 1)
|
||||
|
||||
RigStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorPrimary)
|
||||
|
||||
RoleStyle = lipgloss.NewStyle().
|
||||
Foreground(colorAccent)
|
||||
|
||||
AgentNameStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("15"))
|
||||
|
||||
AgentActiveStyle = lipgloss.NewStyle().
|
||||
Foreground(colorSuccess)
|
||||
|
||||
AgentIdleStyle = lipgloss.NewStyle().
|
||||
Foreground(colorDim)
|
||||
|
||||
// Event stream styles
|
||||
StreamPanelStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorDim).
|
||||
Padding(0, 1)
|
||||
|
||||
TimestampStyle = lipgloss.NewStyle().
|
||||
Foreground(colorDim)
|
||||
|
||||
EventCreateStyle = lipgloss.NewStyle().
|
||||
Foreground(colorSuccess)
|
||||
|
||||
EventUpdateStyle = lipgloss.NewStyle().
|
||||
Foreground(colorPrimary)
|
||||
|
||||
EventCompleteStyle = lipgloss.NewStyle().
|
||||
Foreground(colorSuccess).
|
||||
Bold(true)
|
||||
|
||||
EventFailStyle = lipgloss.NewStyle().
|
||||
Foreground(colorError).
|
||||
Bold(true)
|
||||
|
||||
EventDeleteStyle = lipgloss.NewStyle().
|
||||
Foreground(colorWarning)
|
||||
|
||||
// Status bar styles
|
||||
StatusBarStyle = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("236")).
|
||||
Foreground(colorDim).
|
||||
Padding(0, 1)
|
||||
|
||||
HelpKeyStyle = lipgloss.NewStyle().
|
||||
Foreground(colorHighlight).
|
||||
Bold(true)
|
||||
|
||||
HelpDescStyle = lipgloss.NewStyle().
|
||||
Foreground(colorDim)
|
||||
|
||||
// Focus indicator
|
||||
FocusedBorderStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorPrimary).
|
||||
Padding(0, 1)
|
||||
|
||||
// Role icons
|
||||
RoleIcons = map[string]string{
|
||||
"mayor": "🎩",
|
||||
"witness": "👁",
|
||||
"refinery": "🏭",
|
||||
"crew": "👷",
|
||||
"polecat": "😺",
|
||||
"deacon": "🔔",
|
||||
}
|
||||
|
||||
// Event symbols
|
||||
EventSymbols = map[string]string{
|
||||
"create": "+",
|
||||
"update": "→",
|
||||
"complete": "✓",
|
||||
"fail": "✗",
|
||||
"delete": "⊘",
|
||||
"pin": "📌",
|
||||
}
|
||||
)
|
||||
333
internal/tui/feed/view.go
Normal file
333
internal/tui/feed/view.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// render produces the full TUI output
|
||||
func (m Model) render() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
var sections []string
|
||||
|
||||
// Header
|
||||
sections = append(sections, m.renderHeader())
|
||||
|
||||
// Tree panel (top)
|
||||
treePanel := m.renderTreePanel()
|
||||
sections = append(sections, treePanel)
|
||||
|
||||
// Feed panel (bottom)
|
||||
feedPanel := m.renderFeedPanel()
|
||||
sections = append(sections, feedPanel)
|
||||
|
||||
// Status bar
|
||||
sections = append(sections, m.renderStatusBar())
|
||||
|
||||
// Help (if shown)
|
||||
if m.showHelp {
|
||||
sections = append(sections, m.help.View(m.keys))
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||
}
|
||||
|
||||
// renderHeader renders the top header bar
|
||||
func (m Model) renderHeader() string {
|
||||
title := TitleStyle.Render("GT Feed")
|
||||
|
||||
filter := ""
|
||||
if m.filter != "" {
|
||||
filter = FilterStyle.Render(fmt.Sprintf("Filter: %s", m.filter))
|
||||
} else {
|
||||
filter = FilterStyle.Render("Filter: all")
|
||||
}
|
||||
|
||||
// Right-align filter
|
||||
gap := m.width - lipgloss.Width(title) - lipgloss.Width(filter) - 4
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
|
||||
return HeaderStyle.Render(title + strings.Repeat(" ", gap) + filter)
|
||||
}
|
||||
|
||||
// renderTreePanel renders the agent tree panel with border
|
||||
func (m Model) renderTreePanel() string {
|
||||
style := TreePanelStyle
|
||||
if m.focusedPanel == PanelTree {
|
||||
style = FocusedBorderStyle
|
||||
}
|
||||
return style.Width(m.width - 2).Render(m.treeViewport.View())
|
||||
}
|
||||
|
||||
// renderFeedPanel renders the event feed panel with border
|
||||
func (m Model) renderFeedPanel() string {
|
||||
style := StreamPanelStyle
|
||||
if m.focusedPanel == PanelFeed {
|
||||
style = FocusedBorderStyle
|
||||
}
|
||||
return style.Width(m.width - 2).Render(m.feedViewport.View())
|
||||
}
|
||||
|
||||
// renderTree renders the agent tree content
|
||||
func (m Model) renderTree() string {
|
||||
if len(m.rigs) == 0 {
|
||||
return AgentIdleStyle.Render("No agents active")
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
// Sort rigs by name
|
||||
rigNames := make([]string, 0, len(m.rigs))
|
||||
for name := range m.rigs {
|
||||
rigNames = append(rigNames, name)
|
||||
}
|
||||
sort.Strings(rigNames)
|
||||
|
||||
for _, rigName := range rigNames {
|
||||
rig := m.rigs[rigName]
|
||||
|
||||
// Rig header
|
||||
rigLine := RigStyle.Render(rigName + "/")
|
||||
lines = append(lines, rigLine)
|
||||
|
||||
// Group agents by role
|
||||
byRole := m.groupAgentsByRole(rig.Agents)
|
||||
|
||||
// Render each role group
|
||||
roleOrder := []string{"mayor", "witness", "refinery", "deacon", "crew", "polecat"}
|
||||
for _, role := range roleOrder {
|
||||
agents, ok := byRole[role]
|
||||
if !ok || len(agents) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
icon := RoleIcons[role]
|
||||
if icon == "" {
|
||||
icon = "•"
|
||||
}
|
||||
|
||||
// For crew and polecats, show as expandable group
|
||||
if role == "crew" || role == "polecat" {
|
||||
lines = append(lines, m.renderAgentGroup(icon, role, agents))
|
||||
} else {
|
||||
// Single agents (mayor, witness, refinery)
|
||||
for _, agent := range agents {
|
||||
lines = append(lines, m.renderAgent(icon, agent, 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// groupAgentsByRole groups agents by their role
|
||||
func (m Model) groupAgentsByRole(agents map[string]*Agent) map[string][]*Agent {
|
||||
result := make(map[string][]*Agent)
|
||||
for _, agent := range agents {
|
||||
role := agent.Role
|
||||
if role == "" {
|
||||
role = "unknown"
|
||||
}
|
||||
result[role] = append(result[role], agent)
|
||||
}
|
||||
|
||||
// Sort each group by name
|
||||
for role := range result {
|
||||
sort.Slice(result[role], func(i, j int) bool {
|
||||
return result[role][i].Name < result[role][j].Name
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// renderAgentGroup renders a group of agents (crew or polecats)
|
||||
func (m Model) renderAgentGroup(icon, role string, agents []*Agent) string {
|
||||
var lines []string
|
||||
|
||||
// Group header
|
||||
plural := role
|
||||
if role == "polecat" {
|
||||
plural = "polecats"
|
||||
}
|
||||
header := fmt.Sprintf(" %s %s/", icon, plural)
|
||||
lines = append(lines, RoleStyle.Render(header))
|
||||
|
||||
// Individual agents
|
||||
for _, agent := range agents {
|
||||
lines = append(lines, m.renderAgent("", agent, 5))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderAgent renders a single agent line
|
||||
func (m Model) renderAgent(icon string, agent *Agent, indent int) string {
|
||||
prefix := strings.Repeat(" ", indent)
|
||||
if icon != "" {
|
||||
prefix = strings.Repeat(" ", indent-2) + icon + " "
|
||||
}
|
||||
|
||||
// Name with status indicator
|
||||
name := agent.Name
|
||||
// Extract just the short name if it's a full path
|
||||
if parts := strings.Split(name, "/"); len(parts) > 0 {
|
||||
name = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
nameStyle := AgentIdleStyle
|
||||
statusIndicator := ""
|
||||
if agent.Status == "running" || agent.Status == "working" {
|
||||
nameStyle = AgentActiveStyle
|
||||
statusIndicator = " →"
|
||||
}
|
||||
|
||||
// Last activity
|
||||
activity := ""
|
||||
if agent.LastEvent != nil {
|
||||
age := formatAge(time.Since(agent.LastEvent.Time))
|
||||
msg := agent.LastEvent.Message
|
||||
if len(msg) > 40 {
|
||||
msg = msg[:37] + "..."
|
||||
}
|
||||
activity = fmt.Sprintf(" [%s] %s", age, msg)
|
||||
}
|
||||
|
||||
line := prefix + nameStyle.Render(name+statusIndicator) + TimestampStyle.Render(activity)
|
||||
return line
|
||||
}
|
||||
|
||||
// renderFeed renders the event feed content
|
||||
func (m Model) renderFeed() string {
|
||||
if len(m.events) == 0 {
|
||||
return AgentIdleStyle.Render("No events yet")
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
// Show most recent events first (reversed)
|
||||
start := 0
|
||||
if len(m.events) > 100 {
|
||||
start = len(m.events) - 100
|
||||
}
|
||||
|
||||
for i := len(m.events) - 1; i >= start; i-- {
|
||||
event := m.events[i]
|
||||
lines = append(lines, m.renderEvent(event))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderEvent renders a single event line
|
||||
func (m Model) renderEvent(e Event) string {
|
||||
// Timestamp
|
||||
ts := TimestampStyle.Render(fmt.Sprintf("[%s]", e.Time.Format("15:04:05")))
|
||||
|
||||
// Symbol based on event type
|
||||
symbol := EventSymbols[e.Type]
|
||||
if symbol == "" {
|
||||
symbol = "•"
|
||||
}
|
||||
|
||||
// Style based on event type
|
||||
var symbolStyle lipgloss.Style
|
||||
switch e.Type {
|
||||
case "create":
|
||||
symbolStyle = EventCreateStyle
|
||||
case "update":
|
||||
symbolStyle = EventUpdateStyle
|
||||
case "complete":
|
||||
symbolStyle = EventCompleteStyle
|
||||
case "fail":
|
||||
symbolStyle = EventFailStyle
|
||||
case "delete":
|
||||
symbolStyle = EventDeleteStyle
|
||||
default:
|
||||
symbolStyle = EventUpdateStyle
|
||||
}
|
||||
|
||||
styledSymbol := symbolStyle.Render(symbol)
|
||||
|
||||
// Actor (short form)
|
||||
actor := ""
|
||||
if e.Actor != "" {
|
||||
parts := strings.Split(e.Actor, "/")
|
||||
if len(parts) > 0 {
|
||||
actor = parts[len(parts)-1]
|
||||
}
|
||||
if icon := RoleIcons[e.Role]; icon != "" {
|
||||
actor = icon + " " + actor
|
||||
}
|
||||
actor = RoleStyle.Render(actor) + ": "
|
||||
}
|
||||
|
||||
// Message
|
||||
msg := e.Message
|
||||
if msg == "" && e.Raw != "" {
|
||||
msg = e.Raw
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s %s %s%s", ts, styledSymbol, actor, msg)
|
||||
}
|
||||
|
||||
// renderStatusBar renders the bottom status bar
|
||||
func (m Model) renderStatusBar() string {
|
||||
// Panel indicator
|
||||
panelName := "tree"
|
||||
if m.focusedPanel == PanelFeed {
|
||||
panelName = "feed"
|
||||
}
|
||||
panel := fmt.Sprintf("[%s]", panelName)
|
||||
|
||||
// Event count
|
||||
count := fmt.Sprintf("%d events", len(m.events))
|
||||
|
||||
// Short help
|
||||
help := m.renderShortHelp()
|
||||
|
||||
// Combine
|
||||
left := panel + " " + count
|
||||
gap := m.width - lipgloss.Width(left) - lipgloss.Width(help) - 4
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
|
||||
return StatusBarStyle.Width(m.width).Render(left + strings.Repeat(" ", gap) + help)
|
||||
}
|
||||
|
||||
// renderShortHelp renders abbreviated key hints
|
||||
func (m Model) renderShortHelp() string {
|
||||
hints := []string{
|
||||
HelpKeyStyle.Render("j/k") + HelpDescStyle.Render(":scroll"),
|
||||
HelpKeyStyle.Render("tab") + HelpDescStyle.Render(":switch"),
|
||||
HelpKeyStyle.Render("/") + HelpDescStyle.Render(":search"),
|
||||
HelpKeyStyle.Render("q") + HelpDescStyle.Render(":quit"),
|
||||
HelpKeyStyle.Render("?") + HelpDescStyle.Render(":help"),
|
||||
}
|
||||
return strings.Join(hints, " ")
|
||||
}
|
||||
|
||||
// formatAge formats a duration as a short age string
|
||||
func formatAge(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||
}
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
Reference in New Issue
Block a user