Agent session molecules (gt-gastown-crew-joe, gt-gastown-witness, etc.) update frequently for status tracking, creating noisy entries in the activity feed. This change: - Adds IsAgentSessionBead() to identify agent session beads - Filters out "update" events for agent sessions from the event feed - Still updates the agent tree so status is visible there - Still shows create/complete/fail/delete events for agents The filtering happens in addEvent() in the TUI feed model. Agent session updates are identified by parsing the bead ID pattern and checking for known agent roles (mayor, deacon, witness, refinery, crew, polecat). Resolves: gt-sb6m4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
316 lines
6.9 KiB
Go
316 lines
6.9 KiB
Go
package feed
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/help"
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
)
|
|
|
|
// 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
|
|
|
|
// Event source
|
|
eventChan <-chan Event
|
|
done chan struct{}
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// Capture channels to avoid race with Model mutations
|
|
eventChan := m.eventChan
|
|
done := m.done
|
|
return func() tea.Msg {
|
|
select {
|
|
case event, ok := <-eventChan:
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return eventMsg(event)
|
|
case <-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):
|
|
m.closeOnce.Do(func() { 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) {
|
|
// Update agent tree first (always do this for status tracking)
|
|
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
|
|
}
|
|
}
|
|
|
|
// Filter out noisy agent session updates from the event feed.
|
|
// Agent session molecules (like gt-gastown-crew-joe) update frequently
|
|
// for status tracking. These updates are visible in the agent tree,
|
|
// so we don't need to clutter the event feed with them.
|
|
// We still show create/complete/fail/delete events for agent sessions.
|
|
if e.Type == "update" && beads.IsAgentSessionBead(e.Target) {
|
|
// Skip adding to event feed, but still refresh the view
|
|
// (agent tree was updated above)
|
|
m.updateViewContent()
|
|
return
|
|
}
|
|
|
|
// Add to event feed
|
|
m.events = append(m.events, e)
|
|
|
|
// Keep max 1000 events
|
|
if len(m.events) > 1000 {
|
|
m.events = m.events[len(m.events)-1000:]
|
|
}
|
|
|
|
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()
|
|
}
|