Files
gastown/internal/tui/feed/model.go
gastown/crew/gus 4178940d39 Fix SQL injection and refresh scheduling in convoy panel
- Add convoyID validation with regex pattern ^hq-[a-zA-Z0-9-]+$
  to prevent SQL injection in getTrackedIssueStatus (gt-ur4c4)
- Fix duplicate refresh scheduling: tick schedules fetch, fetch
  schedules next tick (gt-yqfrx)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 23:23:26 -08:00

408 lines
9.4 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
PanelConvoy
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
convoyViewport viewport.Model
feedViewport viewport.Model
// Data
rigs map[string]*Rig
events []Event
convoyState *ConvoyState
townRoot string
// 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),
convoyViewport: 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{}),
}
}
// SetTownRoot sets the town root for convoy fetching
func (m *Model) SetTownRoot(townRoot string) {
m.townRoot = townRoot
}
// Init initializes the model
func (m *Model) Init() tea.Cmd {
return tea.Batch(
m.listenForEvents(),
m.fetchConvoys(),
tea.SetWindowTitle("GT Feed"),
)
}
// eventMsg is sent when a new event arrives
type eventMsg Event
// convoyUpdateMsg is sent when convoy data is refreshed
type convoyUpdateMsg struct {
state *ConvoyState
}
// 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)
})
}
// fetchConvoys returns a command that fetches convoy data
func (m *Model) fetchConvoys() tea.Cmd {
if m.townRoot == "" {
return nil
}
townRoot := m.townRoot
return func() tea.Msg {
state, _ := FetchConvoys(townRoot)
return convoyUpdateMsg{state: state}
}
}
// convoyRefreshTick returns a command that schedules the next convoy refresh
func (m *Model) convoyRefreshTick() tea.Cmd {
return tea.Tick(10*time.Second, func(t time.Time) tea.Msg {
return convoyUpdateMsg{} // Empty state triggers a refresh
})
}
// 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 convoyUpdateMsg:
if msg.state != nil {
// Fresh data arrived - update state and schedule next tick
m.convoyState = msg.state
m.updateViewContent()
cmds = append(cmds, m.convoyRefreshTick())
} else {
// Tick fired - fetch new data
cmds = append(cmds, m.fetchConvoys())
}
case tickMsg:
cmds = append(cmds, tick())
}
// Update viewports
var cmd tea.Cmd
switch m.focusedPanel {
case PanelTree:
m.treeViewport, cmd = m.treeViewport.Update(msg)
case PanelConvoy:
m.convoyViewport, cmd = m.convoyViewport.Update(msg)
case PanelFeed:
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):
// Cycle: Tree -> Convoy -> Feed -> Tree
switch m.focusedPanel {
case PanelTree:
m.focusedPanel = PanelConvoy
case PanelConvoy:
m.focusedPanel = PanelFeed
case PanelFeed:
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.FocusConvoy):
m.focusedPanel = PanelConvoy
return m, nil
case key.Matches(msg, m.keys.Refresh):
m.updateViewContent()
return m, nil
}
// Pass to focused viewport
var cmd tea.Cmd
switch m.focusedPanel {
case PanelTree:
m.treeViewport, cmd = m.treeViewport.Update(msg)
case PanelConvoy:
m.convoyViewport, cmd = m.convoyViewport.Update(msg)
case PanelFeed:
m.feedViewport, cmd = m.feedViewport.Update(msg)
}
return m, cmd
}
// updateViewportSizes recalculates viewport dimensions
func (m *Model) updateViewportSizes() {
// Reserve space: header (1) + borders (6 for 3 panels) + status bar (1) + help (1-2)
headerHeight := 1
statusHeight := 1
helpHeight := 1
if m.showHelp {
helpHeight = 3
}
borderHeight := 6 // top and bottom borders for 3 panels
availableHeight := m.height - headerHeight - statusHeight - helpHeight - borderHeight
if availableHeight < 6 {
availableHeight = 6
}
// Split: 30% tree, 25% convoy, 45% feed
treeHeight := availableHeight * 30 / 100
convoyHeight := availableHeight * 25 / 100
feedHeight := availableHeight - treeHeight - convoyHeight
// Ensure minimum heights
if treeHeight < 3 {
treeHeight = 3
}
if convoyHeight < 3 {
convoyHeight = 3
}
if feedHeight < 3 {
feedHeight = 3
}
contentWidth := m.width - 4 // borders and padding
if contentWidth < 20 {
contentWidth = 20
}
m.treeViewport.Width = contentWidth
m.treeViewport.Height = treeHeight
m.convoyViewport.Width = contentWidth
m.convoyViewport.Height = convoyHeight
m.feedViewport.Width = contentWidth
m.feedViewport.Height = feedHeight
m.updateViewContent()
}
// updateViewContent refreshes the content of all viewports
func (m *Model) updateViewContent() {
m.treeViewport.SetContent(m.renderTree())
m.convoyViewport.SetContent(m.renderConvoys())
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 events with empty bead IDs (malformed mutations)
if e.Type == "update" && e.Target == "" {
return
}
// 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
}
// Deduplicate rapid updates to the same bead within 2 seconds.
// This prevents spam when multiple deps/labels are added to one issue.
if e.Type == "update" && e.Target != "" && len(m.events) > 0 {
lastEvent := m.events[len(m.events)-1]
if lastEvent.Type == "update" && lastEvent.Target == e.Target {
// Same bead updated within 2 seconds - skip duplicate
if e.Time.Sub(lastEvent.Time) < 2*time.Second {
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()
}