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
|
module github.com/steveyegge/gastown
|
||||||
|
|
||||||
go 1.23
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
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
|
github.com/spf13/cobra v1.8.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.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-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/muesli/termenv v0.15.2 // 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/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
golang.org/x/sys v0.19.0 // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // 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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||||
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
|
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||||
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
|
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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/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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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/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=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
|
"github.com/steveyegge/gastown/internal/tui/feed"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -21,6 +24,7 @@ var (
|
|||||||
feedRig string
|
feedRig string
|
||||||
feedNoFollow bool
|
feedNoFollow bool
|
||||||
feedWindow bool
|
feedWindow bool
|
||||||
|
feedPlain bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -34,6 +38,7 @@ func init() {
|
|||||||
feedCmd.Flags().StringVar(&feedType, "type", "", "Filter by event type (create, update, delete, comment)")
|
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().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().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{
|
var feedCmd = &cobra.Command{
|
||||||
@@ -42,15 +47,16 @@ var feedCmd = &cobra.Command{
|
|||||||
Short: "Show real-time activity feed from beads",
|
Short: "Show real-time activity feed from beads",
|
||||||
Long: `Display a real-time feed of issue and molecule state changes.
|
Long: `Display a real-time feed of issue and molecule state changes.
|
||||||
|
|
||||||
This command wraps 'bd activity' to show mutations as they happen,
|
By default, launches an interactive TUI dashboard with:
|
||||||
providing visibility into workflow progress across Gas Town.
|
- 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:
|
Tmux Integration:
|
||||||
Use --window to open the feed in a dedicated tmux window named 'feed'.
|
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.
|
This creates a persistent window you can cycle to with C-b n/p.
|
||||||
If the window already exists, switches to it.
|
|
||||||
|
|
||||||
Event symbols:
|
Event symbols:
|
||||||
+ created/bonded - New issue or molecule created
|
+ created/bonded - New issue or molecule created
|
||||||
@@ -60,12 +66,10 @@ Event symbols:
|
|||||||
⊘ deleted - Issue removed
|
⊘ deleted - Issue removed
|
||||||
|
|
||||||
Examples:
|
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 --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 --since 1h # Events from last hour
|
||||||
gt feed --mol gt-xyz # Filter by issue prefix
|
|
||||||
gt feed --rig gastown # Use gastown rig's beads`,
|
gt feed --rig gastown # Use gastown rig's beads`,
|
||||||
RunE: runFeed,
|
RunE: runFeed,
|
||||||
}
|
}
|
||||||
@@ -112,7 +116,14 @@ func runFeed(cmd *cobra.Command, args []string) error {
|
|||||||
return runFeedInWindow(workDir, bdArgs)
|
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)
|
return runFeedDirect(workDir, bdArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +178,28 @@ func runFeedDirect(workDir string, bdArgs []string) error {
|
|||||||
return syscall.Exec(bdPath, fullArgs, os.Environ())
|
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.
|
// runFeedInWindow opens the feed in a dedicated tmux window.
|
||||||
func runFeedInWindow(workDir string, bdArgs []string) error {
|
func runFeedInWindow(workDir string, bdArgs []string) error {
|
||||||
// Check if we're in tmux
|
// 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