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:
25
go.mod
25
go.mod
@@ -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
54
go.sum
@@ -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=
|
||||
|
||||
@@ -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
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