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:
Steve Yegge
2025-12-28 16:24:28 -08:00
parent dcd996495a
commit c7da650f94
8 changed files with 1321 additions and 27 deletions

25
go.mod
View File

@@ -1,22 +1,33 @@
module github.com/steveyegge/gastown
go 1.23
go 1.24.0
require (
github.com/charmbracelet/lipgloss v1.0.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.8.1
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/x/ansi v0.4.2 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.19.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

54
go.sum
View File

@@ -1,20 +1,42 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -23,9 +45,19 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -7,9 +7,12 @@ import (
"strings"
"syscall"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/tui/feed"
"github.com/steveyegge/gastown/internal/workspace"
"golang.org/x/term"
)
var (
@@ -21,6 +24,7 @@ var (
feedRig string
feedNoFollow bool
feedWindow bool
feedPlain bool
)
func init() {
@@ -34,6 +38,7 @@ func init() {
feedCmd.Flags().StringVar(&feedType, "type", "", "Filter by event type (create, update, delete, comment)")
feedCmd.Flags().StringVar(&feedRig, "rig", "", "Run from specific rig's beads directory")
feedCmd.Flags().BoolVarP(&feedWindow, "window", "w", false, "Open in dedicated tmux window (creates 'feed' window)")
feedCmd.Flags().BoolVar(&feedPlain, "plain", false, "Use plain text output (bd activity) instead of TUI")
}
var feedCmd = &cobra.Command{
@@ -42,15 +47,16 @@ var feedCmd = &cobra.Command{
Short: "Show real-time activity feed from beads",
Long: `Display a real-time feed of issue and molecule state changes.
This command wraps 'bd activity' to show mutations as they happen,
providing visibility into workflow progress across Gas Town.
By default, launches an interactive TUI dashboard with:
- Agent tree (top): Shows all agents organized by role with latest activity
- Event stream (bottom): Chronological feed you can scroll through
- Vim-style navigation: j/k to scroll, tab to switch panels, q to quit
By default, streams in follow mode. Use --no-follow to show events once.
Use --plain for simple text output (wraps bd activity).
Tmux Integration:
Use --window to open the feed in a dedicated tmux window named 'feed'.
This creates a persistent window you can cycle to with C-b n/p.
If the window already exists, switches to it.
Event symbols:
+ created/bonded - New issue or molecule created
@@ -60,12 +66,10 @@ Event symbols:
⊘ deleted - Issue removed
Examples:
gt feed # Stream all events (default: --follow)
gt feed # Launch TUI dashboard
gt feed --plain # Plain text output (bd activity)
gt feed --window # Open in dedicated tmux window
gt feed -w # Short form of --window
gt feed --no-follow # Show last 100 events and exit
gt feed --since 1h # Events from last hour
gt feed --mol gt-xyz # Filter by issue prefix
gt feed --rig gastown # Use gastown rig's beads`,
RunE: runFeed,
}
@@ -112,7 +116,14 @@ func runFeed(cmd *cobra.Command, args []string) error {
return runFeedInWindow(workDir, bdArgs)
}
// Standard mode: exec bd activity directly
// Use TUI by default if running in a terminal and not --plain
useTUI := !feedPlain && term.IsTerminal(int(os.Stdout.Fd()))
if useTUI {
return runFeedTUI(workDir)
}
// Plain mode: exec bd activity directly
return runFeedDirect(workDir, bdArgs)
}
@@ -167,6 +178,28 @@ func runFeedDirect(workDir string, bdArgs []string) error {
return syscall.Exec(bdPath, fullArgs, os.Environ())
}
// runFeedTUI runs the interactive TUI feed.
func runFeedTUI(workDir string) error {
// Create event source from bd activity
source, err := feed.NewBdActivitySource(workDir)
if err != nil {
return fmt.Errorf("creating event source: %w", err)
}
defer source.Close()
// Create model and connect event source
m := feed.NewModel()
m.SetEventChannel(source.Events())
// Run the TUI
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
return fmt.Errorf("running TUI: %w", err)
}
return nil
}
// runFeedInWindow opens the feed in a dedicated tmux window.
func runFeedInWindow(workDir string, bdArgs []string) error {
// Check if we're in tmux

342
internal/tui/feed/events.go Normal file
View 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
View 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
View 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
View 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
View 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))
}