From 75a18c09aa8fc86f51e3a51f6cf6f912d3cdd932 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 14:33:04 +0200 Subject: [PATCH 01/16] feat: migrate from tuikit-go to blitui/blit Replace github.com/moneycaringcoder/tuikit-go v0.7.1 with github.com/blitui/blit v0.1.0. This updates all import paths, the package alias (tuikit -> blit), the tuitest -> btest subpackage, and the Component.Update signature to include the new blit.Context parameter. --- cmd/gitstream/main.go | 54 ++++++------- go.mod | 40 ++++++++-- go.sum | 101 ++++++++++++++++++++++--- internal/ui/debug.go | 10 +-- internal/ui/panel.go | 16 ++-- internal/ui/render.go | 6 +- internal/ui/session_test.go | 12 +-- internal/ui/stream.go | 46 +++++------ internal/ui/stream_test.go | 54 ++++++------- internal/updatewire/updatewire.go | 12 +-- internal/updatewire/updatewire_test.go | 16 ++-- 11 files changed, 237 insertions(+), 130 deletions(-) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index f59d8ad..3a0043b 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -9,7 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - tuikit "github.com/moneycaringcoder/tuikit-go" + blit "github.com/blitui/blit" "github.com/moneycaringcoder/gitstream-tui/internal/config" "github.com/moneycaringcoder/gitstream-tui/internal/github" "github.com/moneycaringcoder/gitstream-tui/internal/ui" @@ -78,35 +78,35 @@ func main() { os.Exit(1) } - tuikit.CleanupOldBinary() + blit.CleanupOldBinary() debugLog := ui.NewDebugLog() stream := ui.NewEventStream(cfg, debugLog) panel := ui.NewStatusPanel() debugOverlay := ui.NewDebugOverlay(debugLog) - detailOverlay := tuikit.NewDetailOverlay(tuikit.DetailOverlayOpts[ui.DisplayEvent]{ + detailOverlay := blit.NewDetailOverlay(blit.DetailOverlayOpts[ui.DisplayEvent]{ Title: "Event Detail", - Render: func(de ui.DisplayEvent, w, h int, theme tuikit.Theme) string { + Render: func(de ui.DisplayEvent, w, h int, theme blit.Theme) string { return renderEventDetail(de, w) }, OnKey: func(de ui.DisplayEvent, key tea.KeyMsg) tea.Cmd { if key.String() == "o" { if url := de.Event.URL(); url != "" { - tuikit.OpenURL(url) + blit.OpenURL(url) } - return tuikit.Consumed() + return blit.Consumed() } return nil }, - KeyBindings: []tuikit.KeyBind{ + KeyBindings: []blit.KeyBind{ {Key: "o", Label: "Open in browser", Group: "DETAIL"}, }, }) stream.DetailOverlay = detailOverlay - // Config editor using tuikit.ConfigEditor - configEditor := tuikit.NewConfigEditor([]tuikit.ConfigField{ + // Config editor using blit.ConfigEditor + configEditor := blit.NewConfigEditor([]blit.ConfigField{ { Label: "Interval (sec)", Group: "Polling", @@ -191,9 +191,9 @@ func main() { return strings.Join(parts, " ") + " " } - app := tuikit.NewApp( - tuikit.WithTheme(tuikit.DefaultTheme()), - tuikit.WithLayout(&tuikit.DualPane{ + app := blit.NewApp( + blit.WithTheme(blit.DefaultTheme()), + blit.WithLayout(&blit.DualPane{ Main: stream, Side: panel, MainName: "Stream", @@ -203,45 +203,45 @@ func main() { SideRight: true, ToggleKey: "", }), - tuikit.WithStatusBar(statusLeft, statusRight), - tuikit.WithHelp(), - tuikit.WithOverlay("Settings", "c", configEditor), - tuikit.WithOverlay("Debug", "D", debugOverlay), - tuikit.WithOverlay("Detail", "", detailOverlay), + blit.WithStatusBar(statusLeft, statusRight), + blit.WithHelp(), + blit.WithOverlay("Settings", "c", configEditor), + blit.WithOverlay("Debug", "D", debugOverlay), + blit.WithOverlay("Detail", "", detailOverlay), // Global keybindings - tuikit.WithKeyBind(tuikit.KeyBind{ + blit.WithKeyBind(blit.KeyBind{ Key: "p", Label: "Pause/resume", Group: "CONTROLS", Handler: func() { stream.TogglePause() }, }), - tuikit.WithKeyBind(tuikit.KeyBind{ + blit.WithKeyBind(blit.KeyBind{ Key: "r", Label: "Refresh now", Group: "CONTROLS", Handler: func() { stream.ForceRefresh() }, }), - tuikit.WithKeyBind(tuikit.KeyBind{ + blit.WithKeyBind(blit.KeyBind{ Key: "s", Label: "Toggle sort", Group: "CONTROLS", Handler: func() { stream.ToggleSort() }, }), - tuikit.WithKeyBind(tuikit.KeyBind{ + blit.WithKeyBind(blit.KeyBind{ Key: "t", Label: "Type filter →", Group: "FILTER", Handler: func() { stream.CycleTypeFilter(true) }, }), - tuikit.WithKeyBind(tuikit.KeyBind{ + blit.WithKeyBind(blit.KeyBind{ Key: "T", Label: "Type filter ←", Group: "FILTER", Handler: func() { stream.CycleTypeFilter(false) }, }), - tuikit.WithKeyBind(tuikit.KeyBind{ + blit.WithKeyBind(blit.KeyBind{ Key: "0", Label: "Clear filters", Group: "FILTER", Handler: func() { stream.ClearFilters() }, }), - tuikit.WithMouseSupport(), - tuikit.WithTickInterval(time.Second), - tuikit.WithAutoUpdate(updatewire.New(version)), + blit.WithMouseSupport(), + blit.WithTickInterval(time.Second), + blit.WithAutoUpdate(updatewire.New(version)), ) // Register repo number filters (1-9) for i := 1; i <= 9; i++ { idx := i - 1 - app.AddKeyBind(tuikit.KeyBind{ + app.AddKeyBind(blit.KeyBind{ Key: fmt.Sprintf("%d", i), Label: fmt.Sprintf("Filter repo %d", i), Group: "FILTER", Handler: func() { repos := cfg.Repos() diff --git a/go.mod b/go.mod index edf082b..9834aa7 100644 --- a/go.mod +++ b/go.mod @@ -3,33 +3,63 @@ module github.com/moneycaringcoder/gitstream-tui go 1.26.1 require ( + github.com/blitui/blit v0.1.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/moneycaringcoder/tuikit-go v0.7.1 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/glamour v1.0.0 // indirect + github.com/charmbracelet/keygen v0.5.3 // indirect + github.com/charmbracelet/log v0.4.1 // indirect + github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 // indirect + github.com/charmbracelet/wish v1.4.7 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/conpty v0.1.0 // indirect + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/input v0.3.4 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.0 // indirect + github.com/charmbracelet/x/windows v0.2.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/creack/pty v1.1.21 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabstv/go-bsdiff v1.0.5 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rcarmo/go-te v0.1.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.34.0 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index df34f81..eae00bf 100644 --- a/go.sum +++ b/go.sum @@ -1,57 +1,134 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/blitui/blit v0.1.0 h1:AfQnTZQHTi7YkRfYUiDfl3dmkuPtSMEmZCilDAuRO0Y= +github.com/blitui/blit v0.1.0/go.mod h1:OQ3XhjGhDneebNJs/ldXqRRXKG1H3+XrYWefdHDD+LY= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= 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.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -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/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= +github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk= +github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I= +github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 h1:Ffon9TbltLGBsT6XE//YvNuu4OAaThXioqalhH11xEw= +github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894/go.mod h1:hg+I6gvlMl16nS9ZzQNgBIrrCasGwEw0QiLsDcP01Ko= +github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= +github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +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/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= +github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= +github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U= +github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= +github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76 h1:eX+pdPPlD279OWgdx7f6KqIRSONuK7egk+jDx7OM3Ac= +github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76/go.mod h1:KjxHHirfLaw19iGT70HvVjHQsL1vq1SRQB4yOsAfy2s= 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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabstv/go-bsdiff v1.0.5 h1:g29MC/38Eaig+iAobW10/CiFvPtin8U3Jj4yNLcNG9k= +github.com/gabstv/go-bsdiff v1.0.5/go.mod h1:/Zz6GK+/f/TMylRtVaW3uwZlb0FZITILfA0q12XKGwg= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.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-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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/moneycaringcoder/tuikit-go v0.7.1 h1:GMtOJjinc/radpd0mEXx6saKo5NyxOR0yvkQLyUiIfQ= -github.com/moneycaringcoder/tuikit-go v0.7.1/go.mod h1:2P2MPQGh/A+vpCcrgh5Taz+XqMlrpPgAsc2cd4W8ucg= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 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/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcarmo/go-te v0.1.0 h1:BH9Ub+A0AVBY5Q00El4QMVaWAMbycVHgMHQI2Kz8J/o= github.com/rcarmo/go-te v0.1.0/go.mod h1:cLsrtroxCubS+OHHwH0riB6xeNESfntaHEeI1jPAedk= +github.com/rivo/uniseg v0.1.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/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/ui/debug.go b/internal/ui/debug.go index e3c8e55..3065c75 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -7,11 +7,11 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - tuikit "github.com/moneycaringcoder/tuikit-go" + blit "github.com/blitui/blit" ) // DebugOverlay shows API stats and recent log entries. -// Implements tuikit.Overlay. +// Implements blit.Overlay. type DebugOverlay struct { debugLog *DebugLog active bool @@ -26,7 +26,7 @@ func NewDebugOverlay(debugLog *DebugLog) *DebugOverlay { func (d *DebugOverlay) Init() tea.Cmd { return nil } -func (d *DebugOverlay) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { +func (d *DebugOverlay) Update(msg tea.Msg, ctx blit.Context) (blit.Component, tea.Cmd) { return d, nil } @@ -94,8 +94,8 @@ func (d *DebugOverlay) View() string { return b.String() } -func (d *DebugOverlay) KeyBindings() []tuikit.KeyBind { - return []tuikit.KeyBind{ +func (d *DebugOverlay) KeyBindings() []blit.KeyBind { + return []blit.KeyBind{ {Key: "D", Label: "Debug log", Group: "OTHER"}, } } diff --git a/internal/ui/panel.go b/internal/ui/panel.go index b6692d4..1648626 100644 --- a/internal/ui/panel.go +++ b/internal/ui/panel.go @@ -6,7 +6,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" - tuikit "github.com/moneycaringcoder/tuikit-go" + blit "github.com/blitui/blit" "github.com/moneycaringcoder/gitstream-tui/internal/gitstatus" ) @@ -16,18 +16,18 @@ type panelLine struct { } // StatusPanel displays local repo git status. -// Implements tuikit.Component. +// Implements blit.Component. type StatusPanel struct { repoStatus []gitstatus.RepoStatus - listView *tuikit.ListView[panelLine] + listView *blit.ListView[panelLine] focused bool width int } func NewStatusPanel() *StatusPanel { p := &StatusPanel{} - p.listView = tuikit.NewListView(tuikit.ListViewOpts[panelLine]{ - RenderItem: func(item panelLine, idx int, isCursor bool, theme tuikit.Theme) string { + p.listView = blit.NewListView(blit.ListViewOpts[panelLine]{ + RenderItem: func(item panelLine, idx int, isCursor bool, theme blit.Theme) string { return item.text }, }) @@ -36,7 +36,7 @@ func NewStatusPanel() *StatusPanel { func (p *StatusPanel) Init() tea.Cmd { return nil } -func (p *StatusPanel) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { +func (p *StatusPanel) Update(msg tea.Msg, ctx blit.Context) (blit.Component, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: cmd := p.listView.HandleKey(msg) @@ -53,8 +53,8 @@ func (p *StatusPanel) View() string { return p.listView.View() } -func (p *StatusPanel) KeyBindings() []tuikit.KeyBind { - return []tuikit.KeyBind{ +func (p *StatusPanel) KeyBindings() []blit.KeyBind { + return []blit.KeyBind{ {Key: "up/k", Label: "Scroll up", Group: "NAVIGATION"}, {Key: "down/j", Label: "Scroll down", Group: "NAVIGATION"}, } diff --git a/internal/ui/render.go b/internal/ui/render.go index bfb8a73..f26a621 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -5,7 +5,7 @@ import ( "time" "github.com/moneycaringcoder/gitstream-tui/internal/github" - tuikit "github.com/moneycaringcoder/tuikit-go" + blit "github.com/blitui/blit" ) const flashDuration = 3 * time.Second @@ -18,7 +18,7 @@ type DisplayEvent struct { func renderEventLine(ev github.Event, now time.Time) string { t := ev.CreatedAt.Local().Format("15:04:05") - rel := tuikit.RelativeTime(ev.CreatedAt, now) + rel := blit.RelativeTime(ev.CreatedAt, now) timeStr := fmt.Sprintf("%s %s", t, rel) label := ev.Label() @@ -29,7 +29,7 @@ func renderEventLine(ev github.Event, now time.Time) string { detailRendered := DetailStyle.Render(detail) if url != "" { - detailRendered = tuikit.OSC8Link(url, detailRendered) + detailRendered = blit.OSC8Link(url, detailRendered) } line := fmt.Sprintf("%s %s %s %s %s", diff --git a/internal/ui/session_test.go b/internal/ui/session_test.go index 4795dfd..ea876df 100644 --- a/internal/ui/session_test.go +++ b/internal/ui/session_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - "github.com/moneycaringcoder/tuikit-go/tuitest" + "github.com/blitui/blit/btest" ) // Session tests: record a scripted flow, save it to testdata/sessions, @@ -21,21 +21,21 @@ func sessionPath(name string) string { return filepath.Join(sessionsDir, name+".tuisess") } -func recordAndVerify(t *testing.T, name string, play func(r *tuitest.SessionRecorder)) { +func recordAndVerify(t *testing.T, name string, play func(r *btest.SessionRecorder)) { t.Helper() path := sessionPath(name) force := os.Getenv("GITSTREAM_UPDATE_SESSIONS") == "1" if _, err := os.Stat(path); force || os.IsNotExist(err) { tm, _ := testApp(t, testEvents()) - rec := tuitest.NewSessionRecorder(tm) + rec := btest.NewSessionRecorder(tm) play(rec) if err := rec.Save(path); err != nil { t.Fatalf("save %s: %v", name, err) } } - sess, err := tuitest.LoadSession(path) + sess, err := btest.LoadSession(path) if err != nil { t.Fatalf("load %s: %v", name, err) } @@ -50,7 +50,7 @@ func recordAndVerify(t *testing.T, name string, play func(r *tuitest.SessionReco // TestSession_RepoFeed covers the primary navigation flow: land on the // event stream, move the cursor down, and return to the top. func TestSession_RepoFeed(t *testing.T) { - recordAndVerify(t, "repo_feed", func(r *tuitest.SessionRecorder) { + recordAndVerify(t, "repo_feed", func(r *btest.SessionRecorder) { r.Key("down").Key("down").Key("up") }) } @@ -58,7 +58,7 @@ func TestSession_RepoFeed(t *testing.T) { // TestSession_TypeFilter covers the filter cycling flow: advance through // two type filters and clear back to the default. func TestSession_TypeFilter(t *testing.T) { - recordAndVerify(t, "type_filter", func(r *tuitest.SessionRecorder) { + recordAndVerify(t, "type_filter", func(r *btest.SessionRecorder) { r.Key("t").Key("t").Key("0") }) } diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 1c46570..8c09160 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -8,7 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - tuikit "github.com/moneycaringcoder/tuikit-go" + blit "github.com/blitui/blit" "github.com/moneycaringcoder/gitstream-tui/internal/config" "github.com/moneycaringcoder/gitstream-tui/internal/discovery" "github.com/moneycaringcoder/gitstream-tui/internal/github" @@ -29,7 +29,7 @@ var typeFilters = []string{ } // EventStream displays a scrollable list of GitHub events. -// Implements tuikit.Component. +// Implements blit.Component. type EventStream struct { cfg *config.Config debugLog *DebugLog @@ -38,8 +38,8 @@ type EventStream struct { seen map[string]bool seenLocalSHAs map[string]bool - listView *tuikit.ListView[DisplayEvent] - poller *tuikit.Poller + listView *blit.ListView[DisplayEvent] + poller *blit.Poller filter string // repo name filter typeFilter string // event type filter @@ -51,7 +51,7 @@ type EventStream struct { focused bool width int - DetailOverlay *tuikit.DetailOverlay[DisplayEvent] + DetailOverlay *blit.DetailOverlay[DisplayEvent] } func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { @@ -64,14 +64,14 @@ func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { knownRepos: append([]string{}, cfg.Repos()...), } - s.listView = tuikit.NewListView(tuikit.ListViewOpts[DisplayEvent]{ - RenderItem: func(item DisplayEvent, idx int, isCursor bool, theme tuikit.Theme) string { + s.listView = blit.NewListView(blit.ListViewOpts[DisplayEvent]{ + RenderItem: func(item DisplayEvent, idx int, isCursor bool, theme blit.Theme) string { return renderEventLine(item.Event, time.Now()) }, - HeaderFunc: func(theme tuikit.Theme) string { + HeaderFunc: func(theme blit.Theme) string { return s.renderHeader() }, - DetailFunc: func(item DisplayEvent, theme tuikit.Theme) string { + DetailFunc: func(item DisplayEvent, theme blit.Theme) string { return s.renderDetailBar(item, theme) }, FlashFunc: func(item DisplayEvent, now time.Time) bool { @@ -80,7 +80,7 @@ func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { DetailHeight: 3, }) - s.poller = tuikit.NewPoller( + s.poller = blit.NewPoller( time.Duration(cfg.Interval)*time.Second, func() tea.Cmd { cmds := []tea.Cmd{pollEvents(cfg, debugLog, false)} @@ -101,29 +101,29 @@ func (s *EventStream) Init() tea.Cmd { ) } -func (s *EventStream) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { +func (s *EventStream) Update(msg tea.Msg, ctx blit.Context) (blit.Component, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: // Enter opens detail overlay instead of browser if msg.String() == "enter" && s.DetailOverlay != nil { if item := s.listView.CursorItem(); item != nil { s.DetailOverlay.Show(*item) - return s, tuikit.Consumed() + return s, blit.Consumed() } } // 'o' opens in browser if msg.String() == "o" { if item := s.listView.CursorItem(); item != nil { if url := item.Event.URL(); url != "" { - tuikit.OpenURL(url) + blit.OpenURL(url) } - return s, tuikit.Consumed() + return s, blit.Consumed() } } cmd := s.listView.HandleKey(msg) return s, cmd - case tuikit.TickMsg: + case blit.TickMsg: s.listView.Refresh() s.poller.SetInterval(time.Duration(s.cfg.Interval) * time.Second) @@ -155,7 +155,7 @@ func (s *EventStream) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { return s, nil } -func (s *EventStream) handleEvents(msg eventsMsg) (tuikit.Component, tea.Cmd) { +func (s *EventStream) handleEvents(msg eventsMsg) (blit.Component, tea.Cmd) { for _, e := range msg.errors { s.debugLog.Error("%s", e) } @@ -193,7 +193,7 @@ func (s *EventStream) handleEvents(msg eventsMsg) (tuikit.Component, tea.Cmd) { return s, nil } -func (s *EventStream) handleGitStatus(msg gitStatusMsg) (tuikit.Component, tea.Cmd) { +func (s *EventStream) handleGitStatus(msg gitStatusMsg) (blit.Component, tea.Cmd) { now := time.Now() newLocal := 0 for _, st := range msg.statuses { @@ -293,9 +293,9 @@ func (s *EventStream) renderHeader() string { return lipgloss.JoinVertical(lipgloss.Left, title, repoList, status, "") } -func (s *EventStream) renderDetailBar(de DisplayEvent, theme tuikit.Theme) string { +func (s *EventStream) renderDetailBar(de DisplayEvent, theme blit.Theme) string { ev := de.Event - divider := tuikit.Divider(s.width, theme) + divider := blit.Divider(s.width, theme) repo := ev.Repo.Name label := ev.Label() @@ -310,7 +310,7 @@ func (s *EventStream) renderDetailBar(de DisplayEvent, theme tuikit.Theme) strin DetailTimeStyle.Render(t), ) - detail := tuikit.Truncate(ev.Detail(), s.width-20) + detail := blit.Truncate(ev.Detail(), s.width-20) urlHint := "" if url := ev.URL(); url != "" { urlHint = DetailTimeStyle.Render(" ↵ open") @@ -320,11 +320,11 @@ func (s *EventStream) renderDetailBar(de DisplayEvent, theme tuikit.Theme) strin return divider + "\n" + line1 + "\n" + line2 } -func (s *EventStream) KeyBindings() []tuikit.KeyBind { +func (s *EventStream) KeyBindings() []blit.KeyBind { bindings := s.listView.KeyBindings() bindings = append(bindings, - tuikit.KeyBind{Key: "enter", Label: "Event detail", Group: "NAVIGATION"}, - tuikit.KeyBind{Key: "o", Label: "Open in browser", Group: "NAVIGATION"}, + blit.KeyBind{Key: "enter", Label: "Event detail", Group: "NAVIGATION"}, + blit.KeyBind{Key: "o", Label: "Open in browser", Group: "NAVIGATION"}, ) return bindings } diff --git a/internal/ui/stream_test.go b/internal/ui/stream_test.go index 4164080..6107407 100644 --- a/internal/ui/stream_test.go +++ b/internal/ui/stream_test.go @@ -4,8 +4,8 @@ import ( "testing" "time" - tuikit "github.com/moneycaringcoder/tuikit-go" - "github.com/moneycaringcoder/tuikit-go/tuitest" + blit "github.com/blitui/blit" + "github.com/blitui/blit/btest" "github.com/moneycaringcoder/gitstream-tui/internal/config" "github.com/moneycaringcoder/gitstream-tui/internal/github" ) @@ -52,29 +52,29 @@ func testEvents() []github.Event { } } -// testApp builds a tuikit.App wrapping an EventStream with injected events. -func testApp(t testing.TB, events []github.Event) (*tuitest.TestModel, *EventStream) { +// testApp builds a blit.App wrapping an EventStream with injected events. +func testApp(t testing.TB, events []github.Event) (*btest.TestModel, *EventStream) { t.Helper() cfg := testConfig() debugLog := NewDebugLog() stream := NewEventStream(cfg, debugLog) panel := NewStatusPanel() - app := tuikit.NewApp( - tuikit.WithLayout(&tuikit.DualPane{ + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ Main: stream, Side: panel, SideWidth: 32, MinMainWidth: 40, SideRight: true, }), - tuikit.WithStatusBar( + blit.WithStatusBar( func() string { return " test status left" }, func() string { return "test status right " }, ), ) - tm := tuitest.NewTestModel(t, app.Model(), 120, 40) + tm := btest.NewTestModel(t, app.Model(), 120, 40) // Inject events directly if len(events) > 0 { @@ -88,20 +88,20 @@ func TestStreamRendersEvents(t *testing.T) { tm, _ := testApp(t, testEvents()) s := tm.Screen() - tuitest.AssertContains(t, s, "alice") - tuitest.AssertContains(t, s, "bob") - tuitest.AssertContains(t, s, "charlie") - tuitest.AssertContains(t, s, "repo-a") - tuitest.AssertContains(t, s, "repo-b") + btest.AssertContains(t, s, "alice") + btest.AssertContains(t, s, "bob") + btest.AssertContains(t, s, "charlie") + btest.AssertContains(t, s, "repo-a") + btest.AssertContains(t, s, "repo-b") } func TestStreamRendersHeader(t *testing.T) { tm, _ := testApp(t, testEvents()) s := tm.Screen() - tuitest.AssertContains(t, s, "gitstream") - tuitest.AssertContains(t, s, "owner/repo-a") - tuitest.AssertContains(t, s, "owner/repo-b") + btest.AssertContains(t, s, "gitstream") + btest.AssertContains(t, s, "owner/repo-a") + btest.AssertContains(t, s, "owner/repo-b") } func TestStreamCursorNavigation(t *testing.T) { @@ -113,8 +113,8 @@ func TestStreamCursorNavigation(t *testing.T) { s := tm.Screen() // Should still render all events - tuitest.AssertContains(t, s, "alice") - tuitest.AssertContains(t, s, "charlie") + btest.AssertContains(t, s, "alice") + btest.AssertContains(t, s, "charlie") } func TestStreamSortToggle(t *testing.T) { @@ -133,7 +133,7 @@ func TestStreamSortToggle(t *testing.T) { t.Error("should be newest first after toggle") } // Events should still render - tuitest.AssertContains(t, s, "alice") + btest.AssertContains(t, s, "alice") } func TestStreamTypeFilter(t *testing.T) { @@ -145,7 +145,7 @@ func TestStreamTypeFilter(t *testing.T) { stream.CycleTypeFilter(true) // -> PushEvent s := tm.Screen() - tuitest.AssertContains(t, s, "alice") // alice has PushEvent + btest.AssertContains(t, s, "alice") // alice has PushEvent // bob's PR event should be filtered out if s.Contains("bob") { t.Error("bob's PullRequestEvent should be filtered when type=PushEvent") @@ -158,7 +158,7 @@ func TestStreamRepoFilter(t *testing.T) { stream.SetRepoFilter("repo-b") s := tm.Screen() - tuitest.AssertContains(t, s, "bob") // bob is in repo-b + btest.AssertContains(t, s, "bob") // bob is in repo-b // alice and charlie are in repo-a, should be hidden if s.Contains("alice") { t.Error("alice should be filtered out when repo=repo-b") @@ -201,8 +201,8 @@ func TestStreamEmptyState(t *testing.T) { s := tm.Screen() // Should still render header - tuitest.AssertContains(t, s, "gitstream") - tuitest.AssertNotEmpty(t, s) + btest.AssertContains(t, s, "gitstream") + btest.AssertNotEmpty(t, s) } func TestStreamResize(t *testing.T) { @@ -211,12 +211,12 @@ func TestStreamResize(t *testing.T) { // Resize to small tm.SendResize(60, 20) s := tm.Screen() - tuitest.AssertNotEmpty(t, s) - tuitest.AssertContains(t, s, "gitstream") + btest.AssertNotEmpty(t, s) + btest.AssertContains(t, s, "gitstream") // Resize to large tm.SendResize(200, 50) s = tm.Screen() - tuitest.AssertNotEmpty(t, s) - tuitest.AssertContains(t, s, "alice") + btest.AssertNotEmpty(t, s) + btest.AssertContains(t, s, "alice") } diff --git a/internal/updatewire/updatewire.go b/internal/updatewire/updatewire.go index 85cb14c..400e437 100644 --- a/internal/updatewire/updatewire.go +++ b/internal/updatewire/updatewire.go @@ -1,30 +1,30 @@ -// Package updatewire builds the tuikit UpdateConfig used by gitstream. +// Package updatewire builds the blit UpdateConfig used by gitstream. // Extracting this into a tiny helper makes the update pipeline testable // against updatetest.NewMockServer without spinning up the full TUI. package updatewire import ( - tuikit "github.com/moneycaringcoder/tuikit-go" + blit "github.com/blitui/blit" ) // New returns a UpdateConfig wired for the gitstream binary. Callers pass // the current version string (typically set via ldflags). The mode is // UpdateForced so release notes carrying a `minimum_version:` marker // trigger the full-screen update gate automatically. -func New(version string) tuikit.UpdateConfig { - return tuikit.UpdateConfig{ +func New(version string) blit.UpdateConfig { + return blit.UpdateConfig{ Owner: "moneycaringcoder", Repo: "gitstream-tui", BinaryName: "gitstream", Version: version, - Mode: tuikit.UpdateForced, + Mode: blit.UpdateForced, } } // NewWithBaseURL is the test hook: it returns the same config as New but // pointed at a mock server URL so update_test.go can assert the full // CheckForUpdate flow without hitting api.github.com. -func NewWithBaseURL(version, baseURL, cacheDir string) tuikit.UpdateConfig { +func NewWithBaseURL(version, baseURL, cacheDir string) blit.UpdateConfig { cfg := New(version) cfg.APIBaseURL = baseURL cfg.CacheDir = cacheDir diff --git a/internal/updatewire/updatewire_test.go b/internal/updatewire/updatewire_test.go index 5931ae2..dc413cd 100644 --- a/internal/updatewire/updatewire_test.go +++ b/internal/updatewire/updatewire_test.go @@ -3,15 +3,15 @@ package updatewire import ( "testing" - tuikit "github.com/moneycaringcoder/tuikit-go" - "github.com/moneycaringcoder/tuikit-go/updatetest" + blit "github.com/blitui/blit" + "github.com/blitui/blit/updatetest" ) // TestNew_DefaultsAreForced asserts the consumer wiring uses UpdateForced // so a minimum_version marker in release notes promotes the update gate. func TestNew_DefaultsAreForced(t *testing.T) { cfg := New("v0.0.0") - if cfg.Mode != tuikit.UpdateForced { + if cfg.Mode != blit.UpdateForced { t.Errorf("mode = %v, want UpdateForced", cfg.Mode) } if cfg.BinaryName != "gitstream" { @@ -35,7 +35,7 @@ func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { defer srv.Close() cfg := NewWithBaseURL("v0.5.0", srv.URL, t.TempDir()) - res, err := tuikit.CheckForUpdate(cfg) + res, err := blit.CheckForUpdate(cfg) if err != nil { t.Fatalf("CheckForUpdate: %v", err) } @@ -58,7 +58,7 @@ func TestCheckForUpdate_CurrentVersionNoGate(t *testing.T) { defer srv.Close() cfg := NewWithBaseURL("v1.0.0", srv.URL, t.TempDir()) - res, err := tuikit.CheckForUpdate(cfg) + res, err := blit.CheckForUpdate(cfg) if err != nil { t.Fatalf("CheckForUpdate: %v", err) } @@ -83,10 +83,10 @@ func TestCheckForUpdate_SkippedVersionNoGate(t *testing.T) { defer srv.Close() cfg := NewWithBaseURL("v0.5.0", srv.URL, t.TempDir()) - if err := tuikit.SkipVersion(cfg, "v1.0.0"); err != nil { + if err := blit.SkipVersion(cfg, "v1.0.0"); err != nil { t.Fatalf("SkipVersion: %v", err) } - res, err := tuikit.CheckForUpdate(cfg) + res, err := blit.CheckForUpdate(cfg) if err != nil { t.Fatalf("CheckForUpdate: %v", err) } @@ -107,7 +107,7 @@ func TestCheckForUpdate_DisabledShortCircuits(t *testing.T) { cfg := NewWithBaseURL("v0.5.0", srv.URL, t.TempDir()) cfg.Disabled = true - res, err := tuikit.CheckForUpdate(cfg) + res, err := blit.CheckForUpdate(cfg) if err != nil { t.Fatalf("CheckForUpdate: %v", err) } From 26b8c236c29418a9dad9853f6952dfd0989967f9 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 14:34:07 +0200 Subject: [PATCH 02/16] docs: update references from tuikit-go to blit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 34829c5..d1c0591 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ interval: 30 # polling interval in seconds ## Built With -- [tuikit-go](https://github.com/moneycaringcoder/tuikit-go) - TUI component toolkit +- [blit](https://github.com/blitui/blit) - TUI component toolkit - [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework - [Lip Gloss](https://github.com/charmbracelet/lipgloss) - Styling - GitHub REST API via `gh` CLI auth From e103698fb56de5dcca63d21e459d65b64a6d76da Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 14:49:47 +0200 Subject: [PATCH 03/16] feat(debug): replace DebugOverlay with blit.LogViewer --- internal/ui/debug.go | 120 +++++++++++++++++++++------------------- internal/ui/debuglog.go | 51 +++++++++++------ internal/ui/styles.go | 3 - 3 files changed, 97 insertions(+), 77 deletions(-) diff --git a/internal/ui/debug.go b/internal/ui/debug.go index 3065c75..59957d8 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -10,24 +10,34 @@ import ( blit "github.com/blitui/blit" ) -// DebugOverlay shows API stats and recent log entries. -// Implements blit.Overlay. +// DebugOverlay shows API stats and recent log entries using blit.LogViewer. +// Implements blit.Component and blit.Overlay. type DebugOverlay struct { - debugLog *DebugLog - active bool - width int - height int - focused bool + logViewer *blit.LogViewer + debugLog *DebugLog + active bool + width int + height int + focused bool } func NewDebugOverlay(debugLog *DebugLog) *DebugOverlay { - return &DebugOverlay{debugLog: debugLog} + lv := blit.NewLogViewer() + debugLog.SetLogViewer(lv) + return &DebugOverlay{ + logViewer: lv, + debugLog: debugLog, + } } func (d *DebugOverlay) Init() tea.Cmd { return nil } func (d *DebugOverlay) Update(msg tea.Msg, ctx blit.Context) (blit.Component, tea.Cmd) { - return d, nil + comp, cmd := d.logViewer.Update(msg, ctx) + if lv, ok := comp.(*blit.LogViewer); ok { + d.logViewer = lv + } + return d, cmd } func (d *DebugOverlay) View() string { @@ -38,71 +48,69 @@ func (d *DebugOverlay) View() string { b.WriteString(title + "\n\n") stats := d.debugLog.GetStats() - b.WriteString(logStatsStyle.Render(" API Stats") + "\n") - b.WriteString(logInfoStyle.Render(fmt.Sprintf(" Total calls: %d", stats.TotalCalls)) + "\n") - b.WriteString(logInfoStyle.Render(fmt.Sprintf(" Successful: %d", stats.SuccessCalls)) + "\n") + + statsHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#3b82f6")).Bold(true) + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")) + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")) + + b.WriteString(statsHeader.Render(" API Stats") + "\n") + b.WriteString(dim.Render(fmt.Sprintf(" Total calls: %d", stats.TotalCalls)) + "\n") + b.WriteString(dim.Render(fmt.Sprintf(" Successful: %d", stats.SuccessCalls)) + "\n") if stats.FailedCalls > 0 { - b.WriteString(logErrorStyle.Render(fmt.Sprintf(" Failed: %d", stats.FailedCalls)) + "\n") + b.WriteString(errStyle.Render(fmt.Sprintf(" Failed: %d", stats.FailedCalls)) + "\n") } else { - b.WriteString(logInfoStyle.Render(fmt.Sprintf(" Failed: %d", stats.FailedCalls)) + "\n") + b.WriteString(dim.Render(fmt.Sprintf(" Failed: %d", stats.FailedCalls)) + "\n") } - b.WriteString(logInfoStyle.Render(fmt.Sprintf(" Total events: %d", stats.TotalEvents)) + "\n") + b.WriteString(dim.Render(fmt.Sprintf(" Total events: %d", stats.TotalEvents)) + "\n") if !stats.LastFetchAt.IsZero() { ago := time.Since(stats.LastFetchAt).Truncate(time.Second) - b.WriteString(logInfoStyle.Render(fmt.Sprintf(" Last fetch: %s ago", ago)) + "\n") + b.WriteString(dim.Render(fmt.Sprintf(" Last fetch: %s ago", ago)) + "\n") } - b.WriteString("\n") - b.WriteString(logStatsStyle.Render(" Recent Log") + "\n") - - entries := d.debugLog.GetEntries() - maxShow := d.height - 14 - if maxShow < 5 { - maxShow = 5 - } - start := 0 - if len(entries) > maxShow { - start = len(entries) - maxShow - } - for i := len(entries) - 1; i >= start; i-- { - e := entries[i] - ts := logTimeStyle.Render(e.Time.Format("15:04:05")) - var levelStyle lipgloss.Style - var prefix string - switch e.Level { - case LogInfo: - levelStyle = logInfoStyle - prefix = "INFO" - case LogWarn: - levelStyle = logWarnStyle - prefix = "WARN" - case LogError: - levelStyle = logErrorStyle - prefix = "ERR " + // Repo health dots + if len(stats.RepoHealth) > 0 { + b.WriteString("\n") + b.WriteString(statsHeader.Render(" Repo Health") + "\n") + green := lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")) + red := lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")) + for repo, h := range stats.RepoHealth { + dot := green.Render("●") + if !h.LastSuccess { + dot = red.Render("●") + } + b.WriteString(fmt.Sprintf(" %s %s", dot, dim.Render(repo)) + "\n") } - line := fmt.Sprintf(" %s %s %s", ts, levelStyle.Render(prefix), levelStyle.Render(e.Message)) - b.WriteString(line + "\n") } - if len(entries) == 0 { - b.WriteString(logInfoStyle.Render(" No log entries yet") + "\n") + // Rate limit + if stats.RateLimit > 0 { + b.WriteString(dim.Render(fmt.Sprintf(" Rate limit: %d/%d", stats.RateRemain, stats.RateLimit)) + "\n") } b.WriteString("\n") - b.WriteString(HelpStyle.PaddingLeft(2).Render("esc close | q quit") + "\n") + b.WriteString(d.logViewer.View()) return b.String() } func (d *DebugOverlay) KeyBindings() []blit.KeyBind { - return []blit.KeyBind{ - {Key: "D", Label: "Debug log", Group: "OTHER"}, + return d.logViewer.KeyBindings() +} + +func (d *DebugOverlay) SetSize(w, h int) { + d.width = w + d.height = h + // Reserve lines for the stats header; give the rest to the log viewer + headerLines := 12 + lvHeight := h - headerLines + if lvHeight < 4 { + lvHeight = 4 } + d.logViewer.SetSize(w, lvHeight) } -func (d *DebugOverlay) SetSize(w, h int) { d.width = w; d.height = h } -func (d *DebugOverlay) Focused() bool { return d.focused } -func (d *DebugOverlay) SetFocused(f bool) { d.focused = f } -func (d *DebugOverlay) IsActive() bool { return d.active } -func (d *DebugOverlay) SetActive(v bool) { d.active = v } -func (d *DebugOverlay) Close() { d.active = false } +func (d *DebugOverlay) Focused() bool { return d.focused } +func (d *DebugOverlay) SetFocused(f bool) { d.focused = f; d.logViewer.SetFocused(f) } +func (d *DebugOverlay) IsActive() bool { return d.active } +func (d *DebugOverlay) SetActive(v bool) { d.active = v } +func (d *DebugOverlay) Close() { d.active = false } diff --git a/internal/ui/debuglog.go b/internal/ui/debuglog.go index d7cc0ec..bf0517e 100644 --- a/internal/ui/debuglog.go +++ b/internal/ui/debuglog.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "github.com/charmbracelet/lipgloss" + blit "github.com/blitui/blit" ) const maxLogEntries = 200 @@ -28,9 +28,10 @@ type LogEntry struct { // DebugLog is a thread-safe circular log buffer. type DebugLog struct { - mu sync.Mutex - entries []LogEntry - stats FetchStats + mu sync.Mutex + entries []LogEntry + stats FetchStats + logViewer *blit.LogViewer } // RepoHealth tracks per-repo fetch health. @@ -58,18 +59,45 @@ func NewDebugLog() *DebugLog { } } +// SetLogViewer wires a blit.LogViewer so that new log entries are also appended to it. +func (d *DebugLog) SetLogViewer(lv *blit.LogViewer) { + d.mu.Lock() + defer d.mu.Unlock() + d.logViewer = lv +} + +// mapLevel converts a ui.LogLevel to a blit.LogLevel. +func mapLevel(l LogLevel) blit.LogLevel { + switch l { + case LogWarn: + return blit.LogWarn + case LogError: + return blit.LogError + default: + return blit.LogInfo + } +} + func (d *DebugLog) Log(level LogLevel, format string, args ...interface{}) { d.mu.Lock() defer d.mu.Unlock() + now := time.Now() msg := fmt.Sprintf(format, args...) d.entries = append(d.entries, LogEntry{ - Time: time.Now(), + Time: now, Level: level, Message: msg, }) if len(d.entries) > maxLogEntries { d.entries = d.entries[len(d.entries)-maxLogEntries:] } + if d.logViewer != nil { + d.logViewer.Append(blit.LogLine{ + Level: mapLevel(level), + Timestamp: now, + Message: msg, + }) + } } func (d *DebugLog) Info(format string, args ...interface{}) { d.Log(LogInfo, format, args...) } @@ -124,16 +152,3 @@ func (d *DebugLog) GetEntries() []LogEntry { return cp } -var ( - logInfoStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")) - logWarnStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#eab308")) - logErrorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ef4444")) - logTimeStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#4b5563")) - logStatsStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#3b82f6")). - Bold(true) -) diff --git a/internal/ui/styles.go b/internal/ui/styles.go index b130a30..ced6b0f 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -45,9 +45,6 @@ var ( Foreground(ColorDim). PaddingLeft(1) - HelpStyle = lipgloss.NewStyle(). - Foreground(ColorDim) - // Status panel styles PanelBorderStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). From 7662377e1264e01bcebd871f2f9fa18bffde603c Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 14:58:53 +0200 Subject: [PATCH 04/16] feat(stream): replace ListView with blit.Table for event feed --- internal/ui/render.go | 17 +++++- internal/ui/stream.go | 132 ++++++++++++++++++++++++++++-------------- internal/ui/styles.go | 28 +++++++++ 3 files changed, 132 insertions(+), 45 deletions(-) diff --git a/internal/ui/render.go b/internal/ui/render.go index f26a621..0667cb5 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -4,8 +4,8 @@ import ( "fmt" "time" - "github.com/moneycaringcoder/gitstream-tui/internal/github" blit "github.com/blitui/blit" + "github.com/moneycaringcoder/gitstream-tui/internal/github" ) const flashDuration = 3 * time.Second @@ -16,6 +16,21 @@ type DisplayEvent struct { AddedAt time.Time } +// eventToRow converts a DisplayEvent into a blit.Row for the table. +func eventToRow(de DisplayEvent) blit.Row { + ev := de.Event + t := ev.CreatedAt.Local().Format("15:04:05") + rel := blit.RelativeTime(ev.CreatedAt, time.Now()) + return blit.Row{ + fmt.Sprintf("%s %s", t, rel), + ev.ShortRepo(), + ev.Label(), + ev.Actor.Login, + ev.Detail(), + } +} + +// renderEventLine renders a single event as a styled string (legacy). func renderEventLine(ev github.Event, now time.Time) string { t := ev.CreatedAt.Local().Format("15:04:05") rel := blit.RelativeTime(ev.CreatedAt, now) diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 8c09160..a4870c4 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -28,18 +28,19 @@ var typeFilters = []string{ "ReleaseEvent", } -// EventStream displays a scrollable list of GitHub events. +// EventStream displays a scrollable table of GitHub events. // Implements blit.Component. type EventStream struct { cfg *config.Config debugLog *DebugLog - allEvents []DisplayEvent - seen map[string]bool - seenLocalSHAs map[string]bool + allEvents []DisplayEvent + filteredEvents []DisplayEvent // parallel slice for row-click lookup + seen map[string]bool + seenLocalSHAs map[string]bool - listView *blit.ListView[DisplayEvent] - poller *blit.Poller + table *blit.Table + poller *blit.Poller filter string // repo name filter typeFilter string // event type filter @@ -56,26 +57,58 @@ type EventStream struct { func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { s := &EventStream{ - cfg: cfg, - debugLog: debugLog, - seen: make(map[string]bool), - seenLocalSHAs: make(map[string]bool), - allEvents: make([]DisplayEvent, 0, 256), - knownRepos: append([]string{}, cfg.Repos()...), + cfg: cfg, + debugLog: debugLog, + seen: make(map[string]bool), + seenLocalSHAs: make(map[string]bool), + allEvents: make([]DisplayEvent, 0, 256), + filteredEvents: make([]DisplayEvent, 0, 256), + knownRepos: append([]string{}, cfg.Repos()...), } - s.listView = blit.NewListView(blit.ListViewOpts[DisplayEvent]{ - RenderItem: func(item DisplayEvent, idx int, isCursor bool, theme blit.Theme) string { - return renderEventLine(item.Event, time.Now()) + columns := []blit.Column{ + {Title: "Time", Width: 2, MaxWidth: 20}, + {Title: "Repo", Width: 2, MaxWidth: 18}, + {Title: "Type", Width: 1, MaxWidth: 10}, + {Title: "Actor", Width: 2, MaxWidth: 22}, + {Title: "Detail", Width: 5}, + } + + s.table = blit.NewTable(columns, nil, blit.TableOpts{ + Filterable: true, + CellRenderer: func(row blit.Row, colIdx int, isCursor bool, theme blit.Theme) string { + if colIdx >= len(row) { + return "" + } + switch colIdx { + case 0: // Time - dim gray + return lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Render(row[colIdx]) + case 2: // Type - colored badge + return blit.Badge(row[colIdx], LabelColor(row[colIdx]), true) + default: + return row[colIdx] + } }, - HeaderFunc: func(theme blit.Theme) string { - return s.renderHeader() + RowStyler: func(row blit.Row, idx int, isCursor bool, theme blit.Theme) *lipgloss.Style { + if idx < len(s.filteredEvents) { + de := s.filteredEvents[idx] + if !de.AddedAt.IsZero() && time.Now().Before(de.AddedAt.Add(flashDuration)) { + st := lipgloss.NewStyle().Background(lipgloss.Color("#1a2a1a")) + return &st + } + } + return nil }, - DetailFunc: func(item DisplayEvent, theme blit.Theme) string { - return s.renderDetailBar(item, theme) + OnRowClick: func(row blit.Row, rowIdx int) { + if rowIdx < len(s.filteredEvents) && s.DetailOverlay != nil { + s.DetailOverlay.Show(s.filteredEvents[rowIdx]) + } }, - FlashFunc: func(item DisplayEvent, now time.Time) bool { - return !item.AddedAt.IsZero() && now.Before(item.AddedAt.Add(flashDuration)) + DetailFunc: func(row blit.Row, rowIdx int, width int, theme blit.Theme) string { + if rowIdx < len(s.filteredEvents) { + return s.renderDetailBar(s.filteredEvents[rowIdx], theme) + } + return "" }, DetailHeight: 3, }) @@ -106,25 +139,28 @@ func (s *EventStream) Update(msg tea.Msg, ctx blit.Context) (blit.Component, tea case tea.KeyMsg: // Enter opens detail overlay instead of browser if msg.String() == "enter" && s.DetailOverlay != nil { - if item := s.listView.CursorItem(); item != nil { - s.DetailOverlay.Show(*item) + idx := s.table.CursorIndex() + if idx >= 0 && idx < len(s.filteredEvents) { + s.DetailOverlay.Show(s.filteredEvents[idx]) return s, blit.Consumed() } } // 'o' opens in browser if msg.String() == "o" { - if item := s.listView.CursorItem(); item != nil { - if url := item.Event.URL(); url != "" { + idx := s.table.CursorIndex() + if idx >= 0 && idx < len(s.filteredEvents) { + if url := s.filteredEvents[idx].Event.URL(); url != "" { blit.OpenURL(url) } return s, blit.Consumed() } } - cmd := s.listView.HandleKey(msg) + // Let table handle its own keys (cursor, search, etc.) + comp, cmd := s.table.Update(msg, ctx) + _ = comp return s, cmd case blit.TickMsg: - s.listView.Refresh() s.poller.SetInterval(time.Duration(s.cfg.Interval) * time.Second) // Check if repos changed (config editor modified them) @@ -184,9 +220,9 @@ func (s *EventStream) handleEvents(msg eventsMsg) (blit.Component, tea.Cmd) { s.rebuildFiltered() if atEdge { if s.newestFirst { - s.listView.ScrollToTop() + s.table.SetCursor(0) } else { - s.listView.ScrollToBottom() + s.table.SetCursor(len(s.filteredEvents) - 1) } } } @@ -234,9 +270,9 @@ func (s *EventStream) handleGitStatus(msg gitStatusMsg) (blit.Component, tea.Cmd s.rebuildFiltered() if atEdge { if s.newestFirst { - s.listView.ScrollToTop() + s.table.SetCursor(0) } else { - s.listView.ScrollToBottom() + s.table.SetCursor(len(s.filteredEvents) - 1) } } } @@ -244,7 +280,8 @@ func (s *EventStream) handleGitStatus(msg gitStatusMsg) (blit.Component, tea.Cmd } func (s *EventStream) View() string { - return s.listView.View() + header := s.renderHeader() + return header + "\n\n" + s.table.View() } func (s *EventStream) renderHeader() string { @@ -290,7 +327,7 @@ func (s *EventStream) renderHeader() string { } status := SubtitleStyle.Render(strings.Join(statusParts, " ")) - return lipgloss.JoinVertical(lipgloss.Left, title, repoList, status, "") + return lipgloss.JoinVertical(lipgloss.Left, title, repoList, status) } func (s *EventStream) renderDetailBar(de DisplayEvent, theme blit.Theme) string { @@ -321,7 +358,7 @@ func (s *EventStream) renderDetailBar(de DisplayEvent, theme blit.Theme) string } func (s *EventStream) KeyBindings() []blit.KeyBind { - bindings := s.listView.KeyBindings() + bindings := s.table.KeyBindings() bindings = append(bindings, blit.KeyBind{Key: "enter", Label: "Event detail", Group: "NAVIGATION"}, blit.KeyBind{Key: "o", Label: "Open in browser", Group: "NAVIGATION"}, @@ -331,13 +368,14 @@ func (s *EventStream) KeyBindings() []blit.KeyBind { func (s *EventStream) SetSize(w, h int) { s.width = w - s.listView.SetSize(w, h) + headerHeight := 4 + s.table.SetSize(w, h-headerHeight) } func (s *EventStream) Focused() bool { return s.focused } func (s *EventStream) SetFocused(f bool) { s.focused = f - s.listView.SetFocused(f) + s.table.SetFocused(f) } // Public methods for app-level keybinding handlers. @@ -377,9 +415,9 @@ func (s *EventStream) ToggleSort() { s.newestFirst = !s.newestFirst s.rebuildFiltered() if s.newestFirst { - s.listView.ScrollToTop() + s.table.SetCursor(0) } else { - s.listView.ScrollToBottom() + s.table.SetCursor(len(s.filteredEvents) - 1) } } @@ -405,28 +443,34 @@ func (s *EventStream) skipEvent(de DisplayEvent) bool { } func (s *EventStream) rebuildFiltered() { - var filtered []DisplayEvent + s.filteredEvents = s.filteredEvents[:0] if s.newestFirst { for i := len(s.allEvents) - 1; i >= 0; i-- { if !s.skipEvent(s.allEvents[i]) { - filtered = append(filtered, s.allEvents[i]) + s.filteredEvents = append(s.filteredEvents, s.allEvents[i]) } } } else { for _, de := range s.allEvents { if !s.skipEvent(de) { - filtered = append(filtered, de) + s.filteredEvents = append(s.filteredEvents, de) } } } - s.listView.SetItems(filtered) + rows := make([]blit.Row, len(s.filteredEvents)) + for i, de := range s.filteredEvents { + rows[i] = eventToRow(de) + } + s.table.SetRows(rows) } func (s *EventStream) isAtNewEdge() bool { + total := len(s.filteredEvents) + idx := s.table.CursorIndex() if s.newestFirst { - return s.listView.IsAtTop() + return idx == 0 } - return s.listView.IsAtBottom() + return idx >= total-1 } func slicesEqual(a, b []string) bool { diff --git a/internal/ui/styles.go b/internal/ui/styles.go index ced6b0f..7c5e423 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -131,3 +131,31 @@ func LabelStyle(eventType string) lipgloss.Style { func DetailLabelStyle(color lipgloss.Color) lipgloss.Style { return lipgloss.NewStyle().Bold(true).Foreground(color) } + +// LabelColor maps a display label (e.g. "PUSH", "PR") back to its color. +func LabelColor(label string) lipgloss.Color { + switch label { + case "LOCAL": + return ColorLocal + case "PUSH": + return ColorPush + case "PR": + return ColorPR + case "REVIEW": + return ColorReview + case "COMMENT": + return ColorComment + case "ISSUE": + return ColorIssue + case "CREATE": + return ColorCreate + case "DELETE": + return ColorDelete + case "RELEASE": + return ColorRelease + case "STAR", "FORK": + return ColorComment + default: + return ColorDim + } +} From 998eaeabf12c9ce931008c94da01162ff8eef6b4 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 15:00:39 +0200 Subject: [PATCH 05/16] refactor(styles): replace LabelStyle/DetailLabelStyle with blit.Badge --- internal/ui/render.go | 2 +- internal/ui/stream.go | 2 +- internal/ui/styles.go | 13 ------------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/internal/ui/render.go b/internal/ui/render.go index 0667cb5..4dae6d0 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -50,7 +50,7 @@ func renderEventLine(ev github.Event, now time.Time) string { line := fmt.Sprintf("%s %s %s %s %s", TimeStyle.Render(timeStr), RepoStyle.Render(repo), - LabelStyle(ev.Type).Render(label), + blit.Badge(label, EventColor(ev.Type), true), ActorStyle.Render(actor), detailRendered, ) diff --git a/internal/ui/stream.go b/internal/ui/stream.go index a4870c4..cb79163 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -341,7 +341,7 @@ func (s *EventStream) renderDetailBar(de DisplayEvent, theme blit.Theme) string color := EventColor(ev.Type) line1 := fmt.Sprintf(" %s %s %s %s", - DetailLabelStyle(color).Render(label), + blit.Badge(label, color, true), DetailRepoStyle.Render(repo), DetailActorStyle.Render(actor), DetailTimeStyle.Render(t), diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 7c5e423..b1eacd2 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -119,19 +119,6 @@ func EventColor(eventType string) lipgloss.Color { } } -// LabelStyle returns a styled label for a given event type. -func LabelStyle(eventType string) lipgloss.Style { - return lipgloss.NewStyle(). - Foreground(EventColor(eventType)). - Width(9). - Bold(true) -} - -// DetailLabelStyle returns a bold label style with a given color. -func DetailLabelStyle(color lipgloss.Color) lipgloss.Style { - return lipgloss.NewStyle().Bold(true).Foreground(color) -} - // LabelColor maps a display label (e.g. "PUSH", "PR") back to its color. func LabelColor(label string) lipgloss.Color { switch label { From 623666923cfadebfc258575bf50c742d64b3b23a Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 15:01:05 +0200 Subject: [PATCH 06/16] feat(stream): add toast notifications for new events and rate limits --- internal/ui/stream.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/ui/stream.go b/internal/ui/stream.go index cb79163..1001a26 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -226,7 +226,24 @@ func (s *EventStream) handleEvents(msg eventsMsg) (blit.Component, tea.Cmd) { } } } - return s, nil + + var cmds []tea.Cmd + + if newCount > 0 && !s.isAtNewEdge() { + cmds = append(cmds, blit.ToastCmd(blit.SeverityInfo, "New events", + fmt.Sprintf("%d new events arrived", newCount), 3*time.Second)) + } + + stats := s.debugLog.GetStats() + if stats.RateLimit > 0 { + ratePct := float64(stats.RateRemain) / float64(stats.RateLimit) * 100 + if ratePct < 20 { + cmds = append(cmds, blit.ToastCmd(blit.SeverityWarn, "Rate limit low", + fmt.Sprintf("API rate limit at %.0f%%", ratePct), 5*time.Second)) + } + } + + return s, tea.Batch(cmds...) } func (s *EventStream) handleGitStatus(msg gitStatusMsg) (blit.Component, tea.Cmd) { From bc7a82e7271783738597ffd78bb04851b4eb4a71 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 15:01:34 +0200 Subject: [PATCH 07/16] feat(detail): render PR/issue/release bodies as Markdown Add Body field to PullRequest, Issue, and Release structs so the GitHub API response populates them. Use blit.Markdown() to render body content with themed formatting in the detail overlay. --- cmd/gitstream/main.go | 19 +++++++++++++++++-- internal/github/events.go | 3 +++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index 3a0043b..adc180e 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -88,7 +88,7 @@ func main() { detailOverlay := blit.NewDetailOverlay(blit.DetailOverlayOpts[ui.DisplayEvent]{ Title: "Event Detail", Render: func(de ui.DisplayEvent, w, h int, theme blit.Theme) string { - return renderEventDetail(de, w) + return renderEventDetail(de, w, theme) }, OnKey: func(de ui.DisplayEvent, key tea.KeyMsg) tea.Cmd { if key.String() == "o" { @@ -293,7 +293,7 @@ Config: ~/.config/gitstream/config.yaml `) } -func renderEventDetail(de ui.DisplayEvent, w int) string { +func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { ev := de.Event labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) valStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ffffff")) @@ -356,18 +356,33 @@ func renderEventDetail(de ui.DisplayEvent, w int) string { lines = append(lines, "") lines = append(lines, labelStyle.Render("PR: ")+valStyle.Render(fmt.Sprintf("#%d %s", pr.Number, pr.Title))) lines = append(lines, labelStyle.Render("State: ")+valStyle.Render(pr.State)) + if pr.Body != "" { + lines = append(lines, "") + lines = append(lines, labelStyle.Render("Description:")) + lines = append(lines, blit.Markdown(pr.Body, theme)) + } } // Issue info if issue := ev.Payload.Issue; issue != nil { lines = append(lines, "") lines = append(lines, labelStyle.Render("Issue: ")+valStyle.Render(fmt.Sprintf("#%d %s", issue.Number, issue.Title))) + if issue.Body != "" { + lines = append(lines, "") + lines = append(lines, labelStyle.Render("Description:")) + lines = append(lines, blit.Markdown(issue.Body, theme)) + } } // Release info if rel := ev.Payload.Release; rel != nil { lines = append(lines, "") lines = append(lines, labelStyle.Render("Release: ")+valStyle.Render(rel.TagName+" — "+rel.Name)) + if rel.Body != "" { + lines = append(lines, "") + lines = append(lines, labelStyle.Render("Release Notes:")) + lines = append(lines, blit.Markdown(rel.Body, theme)) + } } // Compare data (diff stats) diff --git a/internal/github/events.go b/internal/github/events.go index 4667761..3dde8ff 100644 --- a/internal/github/events.go +++ b/internal/github/events.go @@ -51,11 +51,13 @@ type PullRequest struct { Number int `json:"number"` Title string `json:"title"` State string `json:"state"` + Body string `json:"body"` } type Issue struct { Number int `json:"number"` Title string `json:"title"` + Body string `json:"body"` } type Comment struct { @@ -65,6 +67,7 @@ type Comment struct { type Release struct { TagName string `json:"tag_name"` Name string `json:"name"` + Body string `json:"body"` } // CompareResult holds diff stats from the compare API. From 78da85eddea812abb1eb18478baa1591c01e0453 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 15:01:55 +0200 Subject: [PATCH 08/16] feat(stream): add sparkline events-per-minute in header --- internal/ui/panel.go | 126 ++++++++++++++++++++++++++---------------- internal/ui/stream.go | 21 +++++++ 2 files changed, 100 insertions(+), 47 deletions(-) diff --git a/internal/ui/panel.go b/internal/ui/panel.go index 1648626..31bc7e9 100644 --- a/internal/ui/panel.go +++ b/internal/ui/panel.go @@ -22,10 +22,15 @@ type StatusPanel struct { listView *blit.ListView[panelLine] focused bool width int + sections map[string]*blit.CollapsibleSection + headerMap map[int]string // line index → repo remote } func NewStatusPanel() *StatusPanel { - p := &StatusPanel{} + p := &StatusPanel{ + sections: make(map[string]*blit.CollapsibleSection), + headerMap: make(map[int]string), + } p.listView = blit.NewListView(blit.ListViewOpts[panelLine]{ RenderItem: func(item panelLine, idx int, isCursor bool, theme blit.Theme) string { return item.text @@ -39,6 +44,16 @@ func (p *StatusPanel) Init() tea.Cmd { return nil } func (p *StatusPanel) Update(msg tea.Msg, ctx blit.Context) (blit.Component, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + if msg.String() == "enter" || msg.String() == " " { + idx := p.listView.CursorIndex() + if remote, ok := p.headerMap[idx]; ok { + if sec, ok := p.sections[remote]; ok { + sec.Toggle() + p.rebuildContent() + return p, nil + } + } + } cmd := p.listView.HandleKey(msg) return p, cmd case gitStatusMsg: @@ -57,6 +72,7 @@ func (p *StatusPanel) KeyBindings() []blit.KeyBind { return []blit.KeyBind{ {Key: "up/k", Label: "Scroll up", Group: "NAVIGATION"}, {Key: "down/j", Label: "Scroll down", Group: "NAVIGATION"}, + {Key: "enter/space", Label: "Toggle section", Group: "NAVIGATION"}, } } @@ -73,6 +89,7 @@ func (p *StatusPanel) SetFocused(f bool) { func (p *StatusPanel) rebuildContent() { var lines []panelLine + p.headerMap = make(map[int]string) if len(p.repoStatus) == 0 { lines = append(lines, panelLine{text: ""}) @@ -89,61 +106,76 @@ func (p *StatusPanel) rebuildContent() { short = s.Remote[i+1:] } - if s.Error != nil { - lines = append(lines, panelLine{text: PanelRepoStyle.Render("◆ " + short)}) - lines = append(lines, panelLine{text: PanelDimStyle.Render(" error")}) - lines = append(lines, panelLine{text: ""}) - continue + // Get or create collapsible section for this repo. + sec, ok := p.sections[s.Remote] + if !ok { + sec = blit.NewCollapsibleSection(short) + // Default: collapse clean repos, expand dirty repos. + sec.Collapsed = (s.Uncommitted == 0 && s.Unpushed == 0 && s.Error == nil) + p.sections[s.Remote] = sec } - lines = append(lines, panelLine{text: PanelRepoStyle.Render("◆ " + short)}) - lines = append(lines, panelLine{text: PanelDimStyle.Render(fmt.Sprintf(" ᛘ %s", s.Branch))}) + // Header line with collapse indicator. + indicator := "▼" + if sec.Collapsed { + indicator = "▶" + } + p.headerMap[len(lines)] = s.Remote + lines = append(lines, panelLine{text: PanelRepoStyle.Render(indicator + " " + short)}) - if s.Uncommitted == 0 && s.Unpushed == 0 { - lines = append(lines, panelLine{text: PanelCleanStyle.Render(" ✓ clean")}) - } else { - if s.Uncommitted > 0 { - lines = append(lines, panelLine{text: PanelDirtyStyle.Render( - fmt.Sprintf(" ● %d uncommitted", s.Uncommitted))}) - } - if s.Unpushed > 0 { - lines = append(lines, panelLine{text: PanelWarnStyle.Render( - fmt.Sprintf(" ↑ %d unpushed", s.Unpushed))}) - for _, c := range s.UnpushedCommits { - msg := c.Message - maxLen := p.width - 10 - if maxLen < 10 { - maxLen = 10 + if !sec.Collapsed { + if s.Error != nil { + lines = append(lines, panelLine{text: PanelDimStyle.Render(" error")}) + } else { + lines = append(lines, panelLine{text: PanelDimStyle.Render(fmt.Sprintf(" ᛘ %s", s.Branch))}) + + if s.Uncommitted == 0 && s.Unpushed == 0 { + lines = append(lines, panelLine{text: PanelCleanStyle.Render(" ✓ clean")}) + } else { + if s.Uncommitted > 0 { + lines = append(lines, panelLine{text: PanelDirtyStyle.Render( + fmt.Sprintf(" ● %d uncommitted", s.Uncommitted))}) } - if len(msg) > maxLen { - msg = msg[:maxLen-1] + "…" + if s.Unpushed > 0 { + lines = append(lines, panelLine{text: PanelWarnStyle.Render( + fmt.Sprintf(" ↑ %d unpushed", s.Unpushed))}) + for _, c := range s.UnpushedCommits { + msg := c.Message + maxLen := p.width - 10 + if maxLen < 10 { + maxLen = 10 + } + if len(msg) > maxLen { + msg = msg[:maxLen-1] + "…" + } + lines = append(lines, panelLine{text: PanelDimStyle.Render( + fmt.Sprintf(" %s %s", c.SHA, msg))}) + } } - lines = append(lines, panelLine{text: PanelDimStyle.Render( - fmt.Sprintf(" %s %s", c.SHA, msg))}) } - } - } - if !s.HasUpstream { - lines = append(lines, panelLine{text: PanelDimStyle.Render(" ⚠ no upstream")}) - } + if !s.HasUpstream { + lines = append(lines, panelLine{text: PanelDimStyle.Render(" ⚠ no upstream")}) + } - if s.CI != nil { - var ciLine string - switch s.CI.Conclusion { - case "success": - ciLine = PanelCleanStyle.Render(" ✓ CI passed") - case "failure": - ciLine = PanelCIFailStyle.Render(" ✗ CI failed") - case "cancelled": - ciLine = PanelDimStyle.Render(" ○ CI cancelled") - default: - if s.CI.Status == "in_progress" { - ciLine = PanelWarnStyle.Render(" ◌ CI running") - } else { - ciLine = PanelDimStyle.Render(fmt.Sprintf(" ○ CI %s", s.CI.Conclusion)) + if s.CI != nil { + var ciLine string + switch s.CI.Conclusion { + case "success": + ciLine = PanelCleanStyle.Render(" ✓ CI passed") + case "failure": + ciLine = PanelCIFailStyle.Render(" ✗ CI failed") + case "cancelled": + ciLine = PanelDimStyle.Render(" ○ CI cancelled") + default: + if s.CI.Status == "in_progress" { + ciLine = PanelWarnStyle.Render(" ◌ CI running") + } else { + ciLine = PanelDimStyle.Render(fmt.Sprintf(" ○ CI %s", s.CI.Conclusion)) + } + } + lines = append(lines, panelLine{text: ciLine}) } } - lines = append(lines, panelLine{text: ciLine}) } lines = append(lines, panelLine{text: ""}) diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 1001a26..7b3f33c 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -52,6 +52,10 @@ type EventStream struct { focused bool width int + epmWindow []float64 // events-per-minute rolling window (last 30 points) + lastEPMTick time.Time + currentMinCount int + DetailOverlay *blit.DetailOverlay[DisplayEvent] } @@ -163,6 +167,18 @@ func (s *EventStream) Update(msg tea.Msg, ctx blit.Context) (blit.Component, tea case blit.TickMsg: s.poller.SetInterval(time.Duration(s.cfg.Interval) * time.Second) + // EPM window rotation + if s.lastEPMTick.IsZero() { + s.lastEPMTick = msg.Time + } else if msg.Time.Sub(s.lastEPMTick) >= time.Minute { + s.epmWindow = append(s.epmWindow, float64(s.currentMinCount)) + if len(s.epmWindow) > 30 { + s.epmWindow = s.epmWindow[1:] + } + s.currentMinCount = 0 + s.lastEPMTick = msg.Time + } + // Check if repos changed (config editor modified them) currentRepos := s.cfg.Repos() if !slicesEqual(currentRepos, s.knownRepos) { @@ -212,6 +228,7 @@ func (s *EventStream) handleEvents(msg eventsMsg) (blit.Component, tea.Cmd) { s.allEvents = append(s.allEvents, DisplayEvent{Event: ev, AddedAt: addedAt}) newCount++ } + s.currentMinCount += newCount if newCount > 0 { sort.Slice(s.allEvents, func(i, j int) bool { return s.allEvents[i].Event.CreatedAt.Before(s.allEvents[j].Event.CreatedAt) @@ -342,6 +359,10 @@ func (s *EventStream) renderHeader() string { statusParts = append(statusParts, lipgloss.NewStyle().Foreground(rateColor).Render( fmt.Sprintf("API %d/%d", stats.RateRemain, stats.RateLimit))) } + if len(s.epmWindow) >= 2 { + spark, _ := blit.Sparkline(s.epmWindow, 30, nil) + statusParts = append(statusParts, "Activity: "+spark) + } status := SubtitleStyle.Render(strings.Join(statusParts, " ")) return lipgloss.JoinVertical(lipgloss.Left, title, repoList, status) From b91607293b60aeef6deb8e339a1718f40fe20344 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 15:02:56 +0200 Subject: [PATCH 09/16] feat(main): add theme presets with picker and config persistence Add Theme field to Config for persisting theme selection. On startup, resolve the configured theme from blit.Presets() or fall back to DefaultTheme. Register a Picker overlay on ctrl+t that lists all available themes and saves the selection to config. --- cmd/gitstream/main.go | 35 ++++++++++++++++++++++++++++++++++- internal/config/config.go | 1 + 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index adc180e..1b0d89d 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -80,6 +80,27 @@ func main() { blit.CleanupOldBinary() + // Build theme picker for runtime switching + presets := blit.Presets() + var pickerItems []blit.PickerItem + for name := range presets { + n := name + pickerItems = append(pickerItems, blit.PickerItem{ + Title: n, + Value: n, + }) + } + themePicker := blit.NewPicker(pickerItems, blit.PickerOpts{ + Placeholder: "Search themes...", + OnConfirm: func(item blit.PickerItem) { + name := item.Value.(string) + if _, ok := presets[name]; ok { + cfg.Theme = name + config.Save(cfg) + } + }, + }) + debugLog := ui.NewDebugLog() stream := ui.NewEventStream(cfg, debugLog) panel := ui.NewStatusPanel() @@ -192,7 +213,7 @@ func main() { } app := blit.NewApp( - blit.WithTheme(blit.DefaultTheme()), + blit.WithTheme(resolveTheme(cfg.Theme)), blit.WithLayout(&blit.DualPane{ Main: stream, Side: panel, @@ -208,6 +229,7 @@ func main() { blit.WithOverlay("Settings", "c", configEditor), blit.WithOverlay("Debug", "D", debugOverlay), blit.WithOverlay("Detail", "", detailOverlay), + blit.WithOverlay("Theme", "ctrl+t", themePicker), // Global keybindings blit.WithKeyBind(blit.KeyBind{ Key: "p", Label: "Pause/resume", Group: "CONTROLS", @@ -398,3 +420,14 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { return strings.Join(lines, "\n") } + +func resolveTheme(name string) blit.Theme { + if name == "" { + return blit.DefaultTheme() + } + presets := blit.Presets() + if t, ok := presets[name]; ok { + return t + } + return blit.DefaultTheme() +} diff --git a/internal/config/config.go b/internal/config/config.go index ea4ed45..62d7bf6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,7 @@ func (r RepoEntry) MarshalYAML() (interface{}, error) { type Config struct { RepoEntries []RepoEntry `yaml:"repos"` Interval int `yaml:"interval"` + Theme string `yaml:"theme,omitempty"` } // Repos returns just the repo name strings for backward compatibility. From b2bc70d83af252b0d911256c4e1e2d613735cd03 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 15:03:59 +0200 Subject: [PATCH 10/16] refactor(main): use blit.Signal for reactive status bar Replace status bar closures with blit.NewSignal[string] and WithStatusBarSignal for dirty-bit coalescing. Keybind handlers update the right signal after sort/filter/pause state changes. --- cmd/gitstream/main.go | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index 1b0d89d..1518e5a 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -189,13 +189,13 @@ func main() { }, }) - // Status bar: left = help hints, right = active filters - statusLeft := func() string { - return fmt.Sprintf(" ? help s sort t type c config D debug p pause r refresh 1-%d repo 0 clear", - len(cfg.Repos())) - } + // Signal-driven status bar + leftSig := blit.NewSignal(fmt.Sprintf( + " ? help s sort t type c config D debug p pause r refresh 1-%d repo 0 clear", + len(cfg.Repos()))) - statusRight := func() string { + rightSig := blit.NewSignal[string]("") + updateStatusRight := func() { var parts []string sortLabel := "oldest" if stream.IsNewestFirst() { @@ -209,8 +209,9 @@ func main() { ev := github.Event{Type: stream.TypeFilter()} parts = append(parts, "type:"+ev.Label()) } - return strings.Join(parts, " ") + " " + rightSig.Set(strings.Join(parts, " ") + " ") } + updateStatusRight() app := blit.NewApp( blit.WithTheme(resolveTheme(cfg.Theme)), @@ -224,7 +225,7 @@ func main() { SideRight: true, ToggleKey: "", }), - blit.WithStatusBar(statusLeft, statusRight), + blit.WithStatusBarSignal(leftSig, rightSig), blit.WithHelp(), blit.WithOverlay("Settings", "c", configEditor), blit.WithOverlay("Debug", "D", debugOverlay), @@ -233,7 +234,7 @@ func main() { // Global keybindings blit.WithKeyBind(blit.KeyBind{ Key: "p", Label: "Pause/resume", Group: "CONTROLS", - Handler: func() { stream.TogglePause() }, + Handler: func() { stream.TogglePause(); updateStatusRight() }, }), blit.WithKeyBind(blit.KeyBind{ Key: "r", Label: "Refresh now", Group: "CONTROLS", @@ -241,19 +242,19 @@ func main() { }), blit.WithKeyBind(blit.KeyBind{ Key: "s", Label: "Toggle sort", Group: "CONTROLS", - Handler: func() { stream.ToggleSort() }, + Handler: func() { stream.ToggleSort(); updateStatusRight() }, }), blit.WithKeyBind(blit.KeyBind{ Key: "t", Label: "Type filter →", Group: "FILTER", - Handler: func() { stream.CycleTypeFilter(true) }, + Handler: func() { stream.CycleTypeFilter(true); updateStatusRight() }, }), blit.WithKeyBind(blit.KeyBind{ Key: "T", Label: "Type filter ←", Group: "FILTER", - Handler: func() { stream.CycleTypeFilter(false) }, + Handler: func() { stream.CycleTypeFilter(false); updateStatusRight() }, }), blit.WithKeyBind(blit.KeyBind{ Key: "0", Label: "Clear filters", Group: "FILTER", - Handler: func() { stream.ClearFilters() }, + Handler: func() { stream.ClearFilters(); updateStatusRight() }, }), blit.WithMouseSupport(), blit.WithTickInterval(time.Second), From 93f6ea881f374eacffac82f9540c86337e7fde6f Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 15:05:16 +0200 Subject: [PATCH 11/16] feat(main): add vim-style command bar with repo/sort/filter/theme commands Register blit.CommandBar as inline overlay on ':' key. Commands: add, remove/rm, sort, filter (repo:/type:), clear, theme, quit/q. Also add SetTypeFilter() public method to EventStream for direct type filter assignment from command bar and future tabs. --- cmd/gitstream/main.go | 84 +++++++++++++++++++++++++++++++++++++++++++ internal/ui/stream.go | 5 +++ 2 files changed, 89 insertions(+) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index 1518e5a..c428357 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -213,6 +213,89 @@ func main() { } updateStatusRight() + // Vim-style command bar + cmdBar := blit.NewCommandBar([]blit.Command{ + { + Name: "add", Args: true, Hint: "Add a repo (owner/repo)", + Run: func(args string) tea.Cmd { + args = strings.TrimSpace(args) + if args == "" || !strings.Contains(args, "/") { + return nil + } + cfg.RepoEntries = append(cfg.RepoEntries, config.RepoEntry{Name: args}) + config.Save(cfg) + return nil + }, + }, + { + Name: "remove", Aliases: []string{"rm"}, Args: true, Hint: "Remove a repo", + Run: func(args string) tea.Cmd { + args = strings.TrimSpace(args) + filtered := make([]config.RepoEntry, 0, len(cfg.RepoEntries)) + for _, r := range cfg.RepoEntries { + if r.Name != args { + filtered = append(filtered, r) + } + } + cfg.RepoEntries = filtered + config.Save(cfg) + return nil + }, + }, + { + Name: "sort", Args: true, Hint: "Sort newest|oldest", + Run: func(args string) tea.Cmd { + args = strings.TrimSpace(args) + if args == "newest" && !stream.IsNewestFirst() { + stream.ToggleSort() + updateStatusRight() + } else if args == "oldest" && stream.IsNewestFirst() { + stream.ToggleSort() + updateStatusRight() + } + return nil + }, + }, + { + Name: "filter", Args: true, Hint: "filter repo: or type:", + Run: func(args string) tea.Cmd { + args = strings.TrimSpace(args) + if strings.HasPrefix(args, "repo:") { + stream.SetRepoFilter(strings.TrimPrefix(args, "repo:")) + updateStatusRight() + } else if strings.HasPrefix(args, "type:") { + stream.SetTypeFilter(strings.TrimPrefix(args, "type:")) + updateStatusRight() + } + return nil + }, + }, + { + Name: "clear", Hint: "Clear all filters", + Run: func(_ string) tea.Cmd { + stream.ClearFilters() + updateStatusRight() + return nil + }, + }, + { + Name: "theme", Args: true, Hint: "Set theme by name", + Run: func(args string) tea.Cmd { + args = strings.TrimSpace(args) + if t, ok := presets[args]; ok { + cfg.Theme = args + config.Save(cfg) + return blit.SetThemeCmd(t) + } + return nil + }, + }, + { + Name: "quit", Aliases: []string{"q"}, Hint: "Quit", + Run: func(_ string) tea.Cmd { return tea.Quit }, + }, + }) + app := blit.NewApp( blit.WithTheme(resolveTheme(cfg.Theme)), blit.WithLayout(&blit.DualPane{ @@ -231,6 +314,7 @@ func main() { blit.WithOverlay("Debug", "D", debugOverlay), blit.WithOverlay("Detail", "", detailOverlay), blit.WithOverlay("Theme", "ctrl+t", themePicker), + blit.WithOverlay("Command", ":", cmdBar), // Global keybindings blit.WithKeyBind(blit.KeyBind{ Key: "p", Label: "Pause/resume", Group: "CONTROLS", diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 7b3f33c..f8033eb 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -427,6 +427,11 @@ func (s *EventStream) SetRepoFilter(repo string) { s.rebuildFiltered() } +func (s *EventStream) SetTypeFilter(t string) { + s.typeFilter = t + s.rebuildFiltered() +} + func (s *EventStream) ClearFilters() { s.filter = "" s.typeFilter = "" From 222a60c63bdcb2b71978ed151d852a34e41d72a3 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 15:05:40 +0200 Subject: [PATCH 12/16] feat(main): add tab bar for event type filtering with blit.Tabs Wrap the EventStream in a Tabs component with All, Pushes, PRs, Issues, and Local tabs. Each tab sets the type filter via OnChange callback, sharing a single EventStream instance. --- cmd/gitstream/main.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index c428357..2b92898 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -296,10 +296,27 @@ func main() { }, }) + // Tab bar for event type filtering + tabs := blit.NewTabs([]blit.TabItem{ + {Title: "All", Glyph: "◉", Content: stream}, + {Title: "Pushes", Glyph: "↑", Content: stream}, + {Title: "PRs", Glyph: "⎇", Content: stream}, + {Title: "Issues", Glyph: "!", Content: stream}, + {Title: "Local", Glyph: "⌂", Content: stream}, + }, blit.TabsOpts{ + OnChange: func(idx int) { + filters := []string{"", "PushEvent", "PullRequestEvent", "IssuesEvent", "LocalPushEvent"} + if idx < len(filters) { + stream.SetTypeFilter(filters[idx]) + updateStatusRight() + } + }, + }) + app := blit.NewApp( blit.WithTheme(resolveTheme(cfg.Theme)), blit.WithLayout(&blit.DualPane{ - Main: stream, + Main: tabs, Side: panel, MainName: "Stream", SideName: "Local", From 224bed5ad0542a3c02e44147c36ff8c59057537f Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 15:25:13 +0200 Subject: [PATCH 13/16] fix: replace Signal status bar with closures to prevent deadlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal.Set() called from keybind handlers triggers bus.schedule() which calls tea.Program.Send() on an unbuffered channel from within the Update handler — the UI goroutine deadlocks trying to send to itself. WithStatusBar(func() string) closures run during View() instead, avoiding the channel entirely. Also removes 1-9 repo filter keybinds that conflicted with Tabs tab-switching keys. Repo filtering remains available via :filter command bar. --- cmd/gitstream/main.go | 51 ++++++++++--------------------------------- 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index 2b92898..5c8c22a 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -189,13 +189,11 @@ func main() { }, }) - // Signal-driven status bar - leftSig := blit.NewSignal(fmt.Sprintf( - " ? help s sort t type c config D debug p pause r refresh 1-%d repo 0 clear", - len(cfg.Repos()))) - - rightSig := blit.NewSignal[string]("") - updateStatusRight := func() { + // Closure-driven status bar (closures run during View — no signal deadlock) + statusLeft := func() string { + return " ? help s sort t type c config D debug p pause r refresh 1-5 tab 0 clear" + } + statusRight := func() string { var parts []string sortLabel := "oldest" if stream.IsNewestFirst() { @@ -209,9 +207,8 @@ func main() { ev := github.Event{Type: stream.TypeFilter()} parts = append(parts, "type:"+ev.Label()) } - rightSig.Set(strings.Join(parts, " ") + " ") + return strings.Join(parts, " ") + " " } - updateStatusRight() // Vim-style command bar cmdBar := blit.NewCommandBar([]blit.Command{ @@ -248,10 +245,8 @@ func main() { args = strings.TrimSpace(args) if args == "newest" && !stream.IsNewestFirst() { stream.ToggleSort() - updateStatusRight() } else if args == "oldest" && stream.IsNewestFirst() { stream.ToggleSort() - updateStatusRight() } return nil }, @@ -262,10 +257,8 @@ func main() { args = strings.TrimSpace(args) if strings.HasPrefix(args, "repo:") { stream.SetRepoFilter(strings.TrimPrefix(args, "repo:")) - updateStatusRight() } else if strings.HasPrefix(args, "type:") { stream.SetTypeFilter(strings.TrimPrefix(args, "type:")) - updateStatusRight() } return nil }, @@ -274,7 +267,6 @@ func main() { Name: "clear", Hint: "Clear all filters", Run: func(_ string) tea.Cmd { stream.ClearFilters() - updateStatusRight() return nil }, }, @@ -308,7 +300,6 @@ func main() { filters := []string{"", "PushEvent", "PullRequestEvent", "IssuesEvent", "LocalPushEvent"} if idx < len(filters) { stream.SetTypeFilter(filters[idx]) - updateStatusRight() } }, }) @@ -325,7 +316,7 @@ func main() { SideRight: true, ToggleKey: "", }), - blit.WithStatusBarSignal(leftSig, rightSig), + blit.WithStatusBar(statusLeft, statusRight), blit.WithHelp(), blit.WithOverlay("Settings", "c", configEditor), blit.WithOverlay("Debug", "D", debugOverlay), @@ -335,7 +326,7 @@ func main() { // Global keybindings blit.WithKeyBind(blit.KeyBind{ Key: "p", Label: "Pause/resume", Group: "CONTROLS", - Handler: func() { stream.TogglePause(); updateStatusRight() }, + Handler: func() { stream.TogglePause() }, }), blit.WithKeyBind(blit.KeyBind{ Key: "r", Label: "Refresh now", Group: "CONTROLS", @@ -343,43 +334,25 @@ func main() { }), blit.WithKeyBind(blit.KeyBind{ Key: "s", Label: "Toggle sort", Group: "CONTROLS", - Handler: func() { stream.ToggleSort(); updateStatusRight() }, + Handler: func() { stream.ToggleSort() }, }), blit.WithKeyBind(blit.KeyBind{ Key: "t", Label: "Type filter →", Group: "FILTER", - Handler: func() { stream.CycleTypeFilter(true); updateStatusRight() }, + Handler: func() { stream.CycleTypeFilter(true) }, }), blit.WithKeyBind(blit.KeyBind{ Key: "T", Label: "Type filter ←", Group: "FILTER", - Handler: func() { stream.CycleTypeFilter(false); updateStatusRight() }, + Handler: func() { stream.CycleTypeFilter(false) }, }), blit.WithKeyBind(blit.KeyBind{ Key: "0", Label: "Clear filters", Group: "FILTER", - Handler: func() { stream.ClearFilters(); updateStatusRight() }, + Handler: func() { stream.ClearFilters() }, }), blit.WithMouseSupport(), blit.WithTickInterval(time.Second), blit.WithAutoUpdate(updatewire.New(version)), ) - // Register repo number filters (1-9) - for i := 1; i <= 9; i++ { - idx := i - 1 - app.AddKeyBind(blit.KeyBind{ - Key: fmt.Sprintf("%d", i), Label: fmt.Sprintf("Filter repo %d", i), Group: "FILTER", - Handler: func() { - repos := cfg.Repos() - if idx < len(repos) { - repo := repos[idx] - short := repo - if j := strings.LastIndex(repo, "/"); j >= 0 { - short = repo[j+1:] - } - stream.SetRepoFilter(short) - } - }, - }) - } if err := app.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) From 9319647e6428a1a9c3c0f9706ad063b75871572c Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 15:33:03 +0200 Subject: [PATCH 14/16] fix: use goroutine for Signal.Set to prevent status bar deadlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal.Set() from keybind handlers triggers bus.schedule() → p.Send() on bubbletea's unbuffered channel, deadlocking the UI goroutine. Wrapping Set() in a goroutine lets the send happen off the UI thread. WithStatusBarSignal is restored (WithStatusBar closures didn't render content — likely upstream bug). The 1-9 repo filter keybinds remain removed to let Tabs own those keys for tab switching. --- cmd/gitstream/main.go | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index 5c8c22a..b20bb90 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -189,11 +189,13 @@ func main() { }, }) - // Closure-driven status bar (closures run during View — no signal deadlock) - statusLeft := func() string { - return " ? help s sort t type c config D debug p pause r refresh 1-5 tab 0 clear" - } - statusRight := func() string { + // Signal-driven status bar. Set() is called via goroutine to avoid + // deadlocking — bubbletea's p.msgs is unbuffered, and Signal.Set triggers + // bus.schedule → p.Send from the UI goroutine which would block forever. + leftSig := blit.NewSignal( + " ? help s sort t type c config D debug p pause r refresh 1-5 tab 0 clear") + rightSig := blit.NewSignal[string]("") + updateStatusRight := func() { var parts []string sortLabel := "oldest" if stream.IsNewestFirst() { @@ -207,8 +209,10 @@ func main() { ev := github.Event{Type: stream.TypeFilter()} parts = append(parts, "type:"+ev.Label()) } - return strings.Join(parts, " ") + " " + v := strings.Join(parts, " ") + " " + go rightSig.Set(v) } + updateStatusRight() // Vim-style command bar cmdBar := blit.NewCommandBar([]blit.Command{ @@ -245,8 +249,10 @@ func main() { args = strings.TrimSpace(args) if args == "newest" && !stream.IsNewestFirst() { stream.ToggleSort() + updateStatusRight() } else if args == "oldest" && stream.IsNewestFirst() { stream.ToggleSort() + updateStatusRight() } return nil }, @@ -257,8 +263,10 @@ func main() { args = strings.TrimSpace(args) if strings.HasPrefix(args, "repo:") { stream.SetRepoFilter(strings.TrimPrefix(args, "repo:")) + updateStatusRight() } else if strings.HasPrefix(args, "type:") { stream.SetTypeFilter(strings.TrimPrefix(args, "type:")) + updateStatusRight() } return nil }, @@ -267,6 +275,7 @@ func main() { Name: "clear", Hint: "Clear all filters", Run: func(_ string) tea.Cmd { stream.ClearFilters() + updateStatusRight() return nil }, }, @@ -300,6 +309,7 @@ func main() { filters := []string{"", "PushEvent", "PullRequestEvent", "IssuesEvent", "LocalPushEvent"} if idx < len(filters) { stream.SetTypeFilter(filters[idx]) + updateStatusRight() } }, }) @@ -316,7 +326,7 @@ func main() { SideRight: true, ToggleKey: "", }), - blit.WithStatusBar(statusLeft, statusRight), + blit.WithStatusBarSignal(leftSig, rightSig), blit.WithHelp(), blit.WithOverlay("Settings", "c", configEditor), blit.WithOverlay("Debug", "D", debugOverlay), @@ -326,7 +336,7 @@ func main() { // Global keybindings blit.WithKeyBind(blit.KeyBind{ Key: "p", Label: "Pause/resume", Group: "CONTROLS", - Handler: func() { stream.TogglePause() }, + Handler: func() { stream.TogglePause(); updateStatusRight() }, }), blit.WithKeyBind(blit.KeyBind{ Key: "r", Label: "Refresh now", Group: "CONTROLS", @@ -334,19 +344,19 @@ func main() { }), blit.WithKeyBind(blit.KeyBind{ Key: "s", Label: "Toggle sort", Group: "CONTROLS", - Handler: func() { stream.ToggleSort() }, + Handler: func() { stream.ToggleSort(); updateStatusRight() }, }), blit.WithKeyBind(blit.KeyBind{ Key: "t", Label: "Type filter →", Group: "FILTER", - Handler: func() { stream.CycleTypeFilter(true) }, + Handler: func() { stream.CycleTypeFilter(true); updateStatusRight() }, }), blit.WithKeyBind(blit.KeyBind{ Key: "T", Label: "Type filter ←", Group: "FILTER", - Handler: func() { stream.CycleTypeFilter(false) }, + Handler: func() { stream.CycleTypeFilter(false); updateStatusRight() }, }), blit.WithKeyBind(blit.KeyBind{ Key: "0", Label: "Clear filters", Group: "FILTER", - Handler: func() { stream.ClearFilters() }, + Handler: func() { stream.ClearFilters(); updateStatusRight() }, }), blit.WithMouseSupport(), blit.WithTickInterval(time.Second), From 24b3dde99f7b428cbc4c533681e60e9029cfc50a Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 15:36:18 +0200 Subject: [PATCH 15/16] fix: resolve shared stream focus clobbering in Tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 5 tab items shared the same stream pointer. Tabs.SetFocused iterates all items setting Content.SetFocused — the last non-active tab always wins with SetFocused(false), so the stream was permanently unfocused. Keys never reached the table (arrow keys, enter, etc). Fix: assign stream as Content only to the active tab. OnChange moves it into the new slot and nils the rest, so SetFocused only touches the stream once with the correct value. --- cmd/gitstream/main.go | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index b20bb90..f82c14f 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -297,20 +297,32 @@ func main() { }, }) - // Tab bar for event type filtering - tabs := blit.NewTabs([]blit.TabItem{ + // Tab bar for event type filtering. + // Stream is assigned as Content only to the initial tab; OnChange moves + // it into the newly active slot so Tabs.SetFocused doesn't clobber + // the shared stream's focus state (last-writer-wins across 5 items). + tabItems := []blit.TabItem{ {Title: "All", Glyph: "◉", Content: stream}, - {Title: "Pushes", Glyph: "↑", Content: stream}, - {Title: "PRs", Glyph: "⎇", Content: stream}, - {Title: "Issues", Glyph: "!", Content: stream}, - {Title: "Local", Glyph: "⌂", Content: stream}, - }, blit.TabsOpts{ + {Title: "Pushes", Glyph: "↑"}, + {Title: "PRs", Glyph: "⎇"}, + {Title: "Issues", Glyph: "!"}, + {Title: "Local", Glyph: "⌂"}, + } + tabs := blit.NewTabs(tabItems, blit.TabsOpts{ OnChange: func(idx int) { filters := []string{"", "PushEvent", "PullRequestEvent", "IssuesEvent", "LocalPushEvent"} if idx < len(filters) { stream.SetTypeFilter(filters[idx]) updateStatusRight() } + // Move stream into the active tab, clear the rest. + for i := range tabItems { + if i == idx { + tabItems[i].Content = stream + } else { + tabItems[i].Content = nil + } + } }, }) From 1b180214fac006fda7c8578e7b896625a2982bc5 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 15:51:57 +0200 Subject: [PATCH 16/16] fix: implement Themed interface so App theme reaches inner components EventStream, StatusPanel, and DebugOverlay did not implement blit.Themed, so the App's theme never propagated through Tabs to the Table, ListView, or LogViewer. The Table rendered its cursor with a zero-valued Theme (empty colors), making the row selector invisible. --- internal/ui/debug.go | 1 + internal/ui/panel.go | 5 +++++ internal/ui/stream.go | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/internal/ui/debug.go b/internal/ui/debug.go index 59957d8..301e9e6 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -111,6 +111,7 @@ func (d *DebugOverlay) SetSize(w, h int) { func (d *DebugOverlay) Focused() bool { return d.focused } func (d *DebugOverlay) SetFocused(f bool) { d.focused = f; d.logViewer.SetFocused(f) } +func (d *DebugOverlay) SetTheme(t blit.Theme) { d.logViewer.SetTheme(t) } func (d *DebugOverlay) IsActive() bool { return d.active } func (d *DebugOverlay) SetActive(v bool) { d.active = v } func (d *DebugOverlay) Close() { d.active = false } diff --git a/internal/ui/panel.go b/internal/ui/panel.go index 31bc7e9..5905ef7 100644 --- a/internal/ui/panel.go +++ b/internal/ui/panel.go @@ -87,6 +87,11 @@ func (p *StatusPanel) SetFocused(f bool) { p.listView.SetFocused(f) } +// SetTheme implements blit.Themed so the App's theme propagates to the ListView. +func (p *StatusPanel) SetTheme(t blit.Theme) { + p.listView.SetTheme(t) +} + func (p *StatusPanel) rebuildContent() { var lines []panelLine p.headerMap = make(map[int]string) diff --git a/internal/ui/stream.go b/internal/ui/stream.go index f8033eb..5257076 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -416,6 +416,12 @@ func (s *EventStream) SetFocused(f bool) { s.table.SetFocused(f) } +// SetTheme implements blit.Themed so the App's theme propagates through +// Tabs → EventStream → Table. +func (s *EventStream) SetTheme(t blit.Theme) { + s.table.SetTheme(t) +} + // Public methods for app-level keybinding handlers. func (s *EventStream) SetRepoFilter(repo string) {