From 688bf66a221ef389ab3bb8d6ccbe45c527c9c3e4 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 16:58:43 +0200 Subject: [PATCH 01/14] chore: bump blit to v0.1.1 and adopt new APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump github.com/blitui/blit v0.1.0 → v0.1.1 - Replace config I/O with blit.LoadYAML/SaveYAML (atomic writes) - Use ToastInfo/ToastWarn/ToastSuccess convenience functions - Add EmptyText to StatusPanel ListView for empty state - Separate Validate from Set on ConfigEditor fields - Add y keybind to copy event URL to clipboard via OSC 52 --- cmd/gitstream/main.go | 43 +++++++++++++++++++++++---------------- go.mod | 6 ++++-- go.sum | 2 -- internal/config/config.go | 40 +++++++++++++++--------------------- internal/ui/panel.go | 1 + internal/ui/stream.go | 23 +++++++++++++++++---- 6 files changed, 66 insertions(+), 49 deletions(-) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index f82c14f..6173bc8 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -133,22 +133,25 @@ func main() { Group: "Polling", Hint: "How often to poll GitHub for new events (min 5)", Get: func() string { return strconv.Itoa(cfg.Interval) }, - Set: func(v string) error { + Validate: func(v string) error { n, err := strconv.Atoi(v) if err != nil || n < 5 { return fmt.Errorf("must be a number >= 5") } - cfg.Interval = n - config.Save(cfg) return nil }, + Set: func(v string) error { + n, _ := strconv.Atoi(v) + cfg.Interval = n + return config.Save(cfg) + }, }, { Label: "Add repo", Group: "Repos", Hint: "Add a new repo to watch (owner/repo format)", Get: func() string { return "" }, - Set: func(v string) error { + Validate: func(v string) error { v = strings.TrimSpace(v) if v == "" || !strings.Contains(v, "/") { return fmt.Errorf("must be owner/repo format") @@ -158,33 +161,38 @@ func main() { return fmt.Errorf("repo already exists") } } - cfg.RepoEntries = append(cfg.RepoEntries, config.RepoEntry{Name: v}) - config.Save(cfg) return nil }, + Set: func(v string) error { + v = strings.TrimSpace(v) + cfg.RepoEntries = append(cfg.RepoEntries, config.RepoEntry{Name: v}) + return config.Save(cfg) + }, }, { Label: "Remove repo", Group: "Repos", Hint: "Remove a watched repo (owner/repo format)", Get: func() string { return "" }, - Set: func(v string) error { + Validate: func(v string) error { v = strings.TrimSpace(v) - filtered := make([]config.RepoEntry, 0, len(cfg.RepoEntries)) - found := false for _, r := range cfg.RepoEntries { if r.Name == v { - found = true - continue + return nil } - filtered = append(filtered, r) } - if !found { - return fmt.Errorf("repo not found") + return fmt.Errorf("repo not found") + }, + Set: func(v string) error { + v = strings.TrimSpace(v) + filtered := make([]config.RepoEntry, 0, len(cfg.RepoEntries)) + for _, r := range cfg.RepoEntries { + if r.Name != v { + filtered = append(filtered, r) + } } cfg.RepoEntries = filtered - config.Save(cfg) - return nil + return config.Save(cfg) }, }, }) @@ -193,7 +201,7 @@ func main() { // 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") + " ? help s sort t type c config D debug p pause r refresh y copy 1-5 tab 0 clear") rightSig := blit.NewSignal[string]("") updateStatusRight := func() { var parts []string @@ -407,6 +415,7 @@ Keybindings (in TUI): Tab Switch focus Enter Event detail o Open in browser + y Copy URL to clipboard Config: ~/.config/gitstream/config.yaml `) diff --git a/go.mod b/go.mod index 9834aa7..c840853 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,9 @@ module github.com/moneycaringcoder/gitstream-tui go 1.26.1 require ( - github.com/blitui/blit v0.1.0 + github.com/blitui/blit v0.1.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -62,4 +61,7 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/blitui/blit => ../blit diff --git a/go.sum b/go.sum index eae00bf..2ce28b3 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,6 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v 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= diff --git a/internal/config/config.go b/internal/config/config.go index 62d7bf6..7f7bc32 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,9 +3,8 @@ package config import ( "fmt" "os" - "path/filepath" - "gopkg.in/yaml.v3" + blit "github.com/blitui/blit" ) // RepoEntry represents a watched repo with an optional local path override. @@ -68,24 +67,28 @@ func (c *Config) ExplicitPaths() map[string]string { } func DefaultPath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config", "gitstream", "config.yaml") + p, err := blit.DefaultConfigPath("gitstream") + if err != nil { + home, _ := os.UserHomeDir() + return home + "/.config/gitstream/config.yaml" + } + return p } func Load() (*Config, error) { path := DefaultPath() - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("no config found at %s - run 'gitstream add owner/repo' to get started", path) - } + var cfg Config + if err := blit.LoadYAML(path, &cfg); err != nil { return nil, err } - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("invalid config: %w", err) + // LoadYAML returns nil for missing files, so check if we got anything. + if cfg.RepoEntries == nil { + // Check if the file actually exists to give a helpful message. + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil, fmt.Errorf("no config found at %s - run 'gitstream add owner/repo' to get started", path) + } } if cfg.Interval <= 0 { @@ -96,18 +99,7 @@ func Load() (*Config, error) { } func Save(cfg *Config) error { - path := DefaultPath() - - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - - data, err := yaml.Marshal(cfg) - if err != nil { - return err - } - - return os.WriteFile(path, data, 0o644) + return blit.SaveYAML(DefaultPath(), cfg) } func AddRepo(repo string) error { diff --git a/internal/ui/panel.go b/internal/ui/panel.go index 5905ef7..7167709 100644 --- a/internal/ui/panel.go +++ b/internal/ui/panel.go @@ -32,6 +32,7 @@ func NewStatusPanel() *StatusPanel { headerMap: make(map[int]string), } p.listView = blit.NewListView(blit.ListViewOpts[panelLine]{ + EmptyText: "No local repos found", RenderItem: func(item panelLine, idx int, isCursor bool, theme blit.Theme) string { return item.text }, diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 5257076..495a7f6 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -159,6 +159,20 @@ func (s *EventStream) Update(msg tea.Msg, ctx blit.Context) (blit.Component, tea return s, blit.Consumed() } } + // 'y' copies event URL to clipboard + if msg.String() == "y" { + idx := s.table.CursorIndex() + if idx >= 0 && idx < len(s.filteredEvents) { + if url := s.filteredEvents[idx].Event.URL(); url != "" { + return s, tea.Batch( + blit.CopyToClipboardCmd(url), + blit.ToastSuccess("Copied", url), + blit.Consumed(), + ) + } + } + return s, blit.Consumed() + } // Let table handle its own keys (cursor, search, etc.) comp, cmd := s.table.Update(msg, ctx) _ = comp @@ -247,16 +261,16 @@ func (s *EventStream) handleEvents(msg eventsMsg) (blit.Component, tea.Cmd) { 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)) + cmds = append(cmds, blit.ToastInfo("New events", + fmt.Sprintf("%d new events arrived", newCount))) } 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)) + cmds = append(cmds, blit.ToastWarn("Rate limit low", + fmt.Sprintf("API rate limit at %.0f%%", ratePct))) } } @@ -400,6 +414,7 @@ func (s *EventStream) KeyBindings() []blit.KeyBind { bindings = append(bindings, blit.KeyBind{Key: "enter", Label: "Event detail", Group: "NAVIGATION"}, blit.KeyBind{Key: "o", Label: "Open in browser", Group: "NAVIGATION"}, + blit.KeyBind{Key: "y", Label: "Copy URL", Group: "NAVIGATION"}, ) return bindings } From 7278a1c5bdb7fe89772f726c4c4557ed44c61157 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 17:20:29 +0200 Subject: [PATCH 02/14] refactor: derive all UI styles from blit.Theme tokens Replace ~30 hardcoded hex color vars in styles.go with a Styles struct built from blit.Theme. EventColor/LabelColor now accept a theme param and resolve via theme.Color() Extra tokens with semantic fallbacks. All components (EventStream, StatusPanel, DebugOverlay) store the theme and rebuild styles on SetTheme, enabling full runtime theme switching. Also removes dead renderEventLine function. --- cmd/gitstream/main.go | 16 ++-- internal/ui/debug.go | 17 ++-- internal/ui/panel.go | 35 +++++--- internal/ui/render.go | 27 ------ internal/ui/stream.go | 49 ++++++----- internal/ui/styles.go | 197 ++++++++++++++++++------------------------ 6 files changed, 151 insertions(+), 190 deletions(-) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index 6173bc8..b77a260 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -423,9 +423,9 @@ Config: ~/.config/gitstream/config.yaml 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")) - color := ui.EventColor(ev.Type) + labelStyle := lipgloss.NewStyle().Foreground(theme.Muted) + valStyle := lipgloss.NewStyle().Foreground(theme.Text) + color := ui.EventColor(ev.Type, theme) typeStyle := lipgloss.NewStyle().Foreground(color).Bold(true) lines := []string{ @@ -444,7 +444,6 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { detail := ev.Detail() if detail != "" { lines = append(lines, labelStyle.Render("Detail:")) - // Word-wrap detail to width maxW := w - 2 if maxW < 20 { maxW = 20 @@ -462,6 +461,7 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { if len(ev.Payload.Commits) > 0 { lines = append(lines, "") lines = append(lines, labelStyle.Render("Commits:")) + shaStyle := lipgloss.NewStyle().Foreground(theme.Warn) for _, c := range ev.Payload.Commits { sha := c.SHA if len(sha) > 7 { @@ -475,7 +475,7 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { if maxMsg > 0 && len(msg) > maxMsg { msg = msg[:maxMsg-1] + "…" } - lines = append(lines, " "+lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaa00")).Render(sha)+" "+valStyle.Render(msg)) + lines = append(lines, " "+shaStyle.Render(sha)+" "+valStyle.Render(msg)) } } @@ -517,9 +517,11 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { if cd := ev.CompareData; cd != nil { lines = append(lines, "") lines = append(lines, labelStyle.Render(fmt.Sprintf("Files changed: %d, Commits: %d", len(cd.Files), cd.TotalCommits))) + addStyle := lipgloss.NewStyle().Foreground(theme.Positive) + delStyle := lipgloss.NewStyle().Foreground(theme.Negative) for _, f := range cd.Files { - adds := lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")).Render(fmt.Sprintf("+%d", f.Additions)) - dels := lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")).Render(fmt.Sprintf("-%d", f.Deletions)) + adds := addStyle.Render(fmt.Sprintf("+%d", f.Additions)) + dels := delStyle.Render(fmt.Sprintf("-%d", f.Deletions)) lines = append(lines, " "+adds+" "+dels+" "+valStyle.Render(f.Filename)) } } diff --git a/internal/ui/debug.go b/internal/ui/debug.go index 301e9e6..4b03720 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -19,6 +19,7 @@ type DebugOverlay struct { width int height int focused bool + theme blit.Theme } func NewDebugOverlay(debugLog *DebugLog) *DebugOverlay { @@ -27,6 +28,7 @@ func NewDebugOverlay(debugLog *DebugLog) *DebugOverlay { return &DebugOverlay{ logViewer: lv, debugLog: debugLog, + theme: blit.DefaultTheme(), } } @@ -42,16 +44,17 @@ func (d *DebugOverlay) Update(msg tea.Msg, ctx blit.Context) (blit.Component, te func (d *DebugOverlay) View() string { var b strings.Builder + th := d.theme - title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#ffffff")). + title := lipgloss.NewStyle().Bold(true).Foreground(th.Text). PaddingLeft(1).Render("─── DEBUG LOG ───") b.WriteString(title + "\n\n") stats := d.debugLog.GetStats() - statsHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#3b82f6")).Bold(true) - dim := lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")) - errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")) + statsHeader := lipgloss.NewStyle().Foreground(th.Accent).Bold(true) + dim := lipgloss.NewStyle().Foreground(th.Muted) + errStyle := lipgloss.NewStyle().Foreground(th.Negative) b.WriteString(statsHeader.Render(" API Stats") + "\n") b.WriteString(dim.Render(fmt.Sprintf(" Total calls: %d", stats.TotalCalls)) + "\n") @@ -71,8 +74,8 @@ func (d *DebugOverlay) View() string { 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")) + green := lipgloss.NewStyle().Foreground(th.Positive) + red := lipgloss.NewStyle().Foreground(th.Negative) for repo, h := range stats.RepoHealth { dot := green.Render("●") if !h.LastSuccess { @@ -111,7 +114,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) SetTheme(t blit.Theme) { d.theme = t; 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 7167709..89e51bf 100644 --- a/internal/ui/panel.go +++ b/internal/ui/panel.go @@ -22,14 +22,19 @@ type StatusPanel struct { listView *blit.ListView[panelLine] focused bool width int + theme blit.Theme + styles Styles sections map[string]*blit.CollapsibleSection headerMap map[int]string // line index → repo remote } func NewStatusPanel() *StatusPanel { + th := blit.DefaultTheme() p := &StatusPanel{ sections: make(map[string]*blit.CollapsibleSection), headerMap: make(map[int]string), + theme: th, + styles: NewStyles(th), } p.listView = blit.NewListView(blit.ListViewOpts[panelLine]{ EmptyText: "No local repos found", @@ -90,6 +95,8 @@ func (p *StatusPanel) SetFocused(f bool) { // SetTheme implements blit.Themed so the App's theme propagates to the ListView. func (p *StatusPanel) SetTheme(t blit.Theme) { + p.theme = t + p.styles = NewStyles(t) p.listView.SetTheme(t) } @@ -99,7 +106,7 @@ func (p *StatusPanel) rebuildContent() { if len(p.repoStatus) == 0 { lines = append(lines, panelLine{text: ""}) - lines = append(lines, panelLine{text: PanelDimStyle.Render("Scanning for repos...")}) + lines = append(lines, panelLine{text: p.styles.PanelDim.Render("Scanning for repos...")}) p.listView.SetItems(lines) return } @@ -127,23 +134,23 @@ func (p *StatusPanel) rebuildContent() { indicator = "▶" } p.headerMap[len(lines)] = s.Remote - lines = append(lines, panelLine{text: PanelRepoStyle.Render(indicator + " " + short)}) + lines = append(lines, panelLine{text: p.styles.PanelRepo.Render(indicator + " " + short)}) if !sec.Collapsed { if s.Error != nil { - lines = append(lines, panelLine{text: PanelDimStyle.Render(" error")}) + lines = append(lines, panelLine{text: p.styles.PanelDim.Render(" error")}) } else { - lines = append(lines, panelLine{text: PanelDimStyle.Render(fmt.Sprintf(" ᛘ %s", s.Branch))}) + lines = append(lines, panelLine{text: p.styles.PanelDim.Render(fmt.Sprintf(" ᛘ %s", s.Branch))}) if s.Uncommitted == 0 && s.Unpushed == 0 { - lines = append(lines, panelLine{text: PanelCleanStyle.Render(" ✓ clean")}) + lines = append(lines, panelLine{text: p.styles.PanelClean.Render(" ✓ clean")}) } else { if s.Uncommitted > 0 { - lines = append(lines, panelLine{text: PanelDirtyStyle.Render( + lines = append(lines, panelLine{text: p.styles.PanelDirty.Render( fmt.Sprintf(" ● %d uncommitted", s.Uncommitted))}) } if s.Unpushed > 0 { - lines = append(lines, panelLine{text: PanelWarnStyle.Render( + lines = append(lines, panelLine{text: p.styles.PanelWarn.Render( fmt.Sprintf(" ↑ %d unpushed", s.Unpushed))}) for _, c := range s.UnpushedCommits { msg := c.Message @@ -154,29 +161,29 @@ func (p *StatusPanel) rebuildContent() { if len(msg) > maxLen { msg = msg[:maxLen-1] + "…" } - lines = append(lines, panelLine{text: PanelDimStyle.Render( + lines = append(lines, panelLine{text: p.styles.PanelDim.Render( fmt.Sprintf(" %s %s", c.SHA, msg))}) } } } if !s.HasUpstream { - lines = append(lines, panelLine{text: PanelDimStyle.Render(" ⚠ no upstream")}) + lines = append(lines, panelLine{text: p.styles.PanelDim.Render(" ⚠ no upstream")}) } if s.CI != nil { var ciLine string switch s.CI.Conclusion { case "success": - ciLine = PanelCleanStyle.Render(" ✓ CI passed") + ciLine = p.styles.PanelClean.Render(" ✓ CI passed") case "failure": - ciLine = PanelCIFailStyle.Render(" ✗ CI failed") + ciLine = p.styles.PanelCIFail.Render(" ✗ CI failed") case "cancelled": - ciLine = PanelDimStyle.Render(" ○ CI cancelled") + ciLine = p.styles.PanelDim.Render(" ○ CI cancelled") default: if s.CI.Status == "in_progress" { - ciLine = PanelWarnStyle.Render(" ◌ CI running") + ciLine = p.styles.PanelWarn.Render(" ◌ CI running") } else { - ciLine = PanelDimStyle.Render(fmt.Sprintf(" ○ CI %s", s.CI.Conclusion)) + ciLine = p.styles.PanelDim.Render(fmt.Sprintf(" ○ CI %s", s.CI.Conclusion)) } } lines = append(lines, panelLine{text: ciLine}) diff --git a/internal/ui/render.go b/internal/ui/render.go index 4dae6d0..dbfdeec 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -30,30 +30,3 @@ func eventToRow(de DisplayEvent) blit.Row { } } -// 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) - timeStr := fmt.Sprintf("%s %s", t, rel) - - label := ev.Label() - detail := ev.Detail() - actor := ev.Actor.Login - repo := ev.ShortRepo() - url := ev.URL() - - detailRendered := DetailStyle.Render(detail) - if url != "" { - detailRendered = blit.OSC8Link(url, detailRendered) - } - - line := fmt.Sprintf("%s %s %s %s %s", - TimeStyle.Render(timeStr), - RepoStyle.Render(repo), - blit.Badge(label, EventColor(ev.Type), true), - ActorStyle.Render(actor), - detailRendered, - ) - - return line -} diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 495a7f6..d18b824 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -51,6 +51,8 @@ type EventStream struct { focused bool width int + theme blit.Theme + styles Styles epmWindow []float64 // events-per-minute rolling window (last 30 points) lastEPMTick time.Time @@ -60,6 +62,7 @@ type EventStream struct { } func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { + th := blit.DefaultTheme() s := &EventStream{ cfg: cfg, debugLog: debugLog, @@ -68,6 +71,8 @@ func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { allEvents: make([]DisplayEvent, 0, 256), filteredEvents: make([]DisplayEvent, 0, 256), knownRepos: append([]string{}, cfg.Repos()...), + theme: th, + styles: NewStyles(th), } columns := []blit.Column{ @@ -85,10 +90,10 @@ func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { return "" } switch colIdx { - case 0: // Time - dim gray - return lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Render(row[colIdx]) + case 0: // Time + return lipgloss.NewStyle().Foreground(theme.Muted).Render(row[colIdx]) case 2: // Type - colored badge - return blit.Badge(row[colIdx], LabelColor(row[colIdx]), true) + return blit.Badge(row[colIdx], LabelColor(row[colIdx], theme), true) default: return row[colIdx] } @@ -97,7 +102,7 @@ func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { 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")) + st := lipgloss.NewStyle().Background(theme.Flash) return &st } } @@ -333,13 +338,13 @@ func (s *EventStream) View() string { } func (s *EventStream) renderHeader() string { - title := TitleStyle.Render("gitstream") - repoList := SubtitleStyle.Render(fmt.Sprintf("Watching: %s", strings.Join(s.cfg.Repos(), ", "))) + title := s.styles.Title.Render("gitstream") + repoList := s.styles.Subtitle.Render(fmt.Sprintf("Watching: %s", strings.Join(s.cfg.Repos(), ", "))) // Status line: poll info + health dots + rate limit var statusParts []string if s.poller.IsPaused() { - statusParts = append(statusParts, lipgloss.NewStyle().Foreground(lipgloss.Color("#eab308")).Render("[PAUSED]")) + statusParts = append(statusParts, lipgloss.NewStyle().Foreground(s.theme.Warn).Render("[PAUSED]")) } else if !s.poller.LastPoll().IsZero() { ago := time.Since(s.poller.LastPoll()).Truncate(time.Second) statusParts = append(statusParts, fmt.Sprintf("Poll %s ago", ago)) @@ -350,25 +355,25 @@ func (s *EventStream) renderHeader() string { if i := strings.LastIndex(repo, "/"); i >= 0 { short = repo[i+1:] } - dot := lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Render("○") + dot := lipgloss.NewStyle().Foreground(s.theme.Muted).Render("○") if h, ok := stats.RepoHealth[repo]; ok { if h.LastSuccess { - dot = lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")).Render("●") + dot = lipgloss.NewStyle().Foreground(s.theme.Positive).Render("●") } else if h.UsingCache && h.FailStreak < 10 { - dot = lipgloss.NewStyle().Foreground(lipgloss.Color("#eab308")).Render("●") + dot = lipgloss.NewStyle().Foreground(s.theme.Warn).Render("●") } else { - dot = lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")).Render("●") + dot = lipgloss.NewStyle().Foreground(s.theme.Negative).Render("●") } } statusParts = append(statusParts, dot+" "+short) } if stats.RateLimit > 0 { ratePct := float64(stats.RateRemain) / float64(stats.RateLimit) * 100 - rateColor := lipgloss.Color("#22c55e") + rateColor := s.theme.Positive if ratePct < 20 { - rateColor = lipgloss.Color("#ef4444") + rateColor = s.theme.Negative } else if ratePct < 50 { - rateColor = lipgloss.Color("#eab308") + rateColor = s.theme.Warn } statusParts = append(statusParts, lipgloss.NewStyle().Foreground(rateColor).Render( fmt.Sprintf("API %d/%d", stats.RateRemain, stats.RateLimit))) @@ -377,7 +382,7 @@ func (s *EventStream) renderHeader() string { spark, _ := blit.Sparkline(s.epmWindow, 30, nil) statusParts = append(statusParts, "Activity: "+spark) } - status := SubtitleStyle.Render(strings.Join(statusParts, " ")) + status := s.styles.Subtitle.Render(strings.Join(statusParts, " ")) return lipgloss.JoinVertical(lipgloss.Left, title, repoList, status) } @@ -390,21 +395,21 @@ func (s *EventStream) renderDetailBar(de DisplayEvent, theme blit.Theme) string label := ev.Label() actor := ev.Actor.Login t := ev.CreatedAt.Local().Format("2006-01-02 15:04:05") - color := EventColor(ev.Type) + color := EventColor(ev.Type, theme) line1 := fmt.Sprintf(" %s %s %s %s", blit.Badge(label, color, true), - DetailRepoStyle.Render(repo), - DetailActorStyle.Render(actor), - DetailTimeStyle.Render(t), + s.styles.DetailRepo.Render(repo), + s.styles.DetailActor.Render(actor), + s.styles.DetailTime.Render(t), ) detail := blit.Truncate(ev.Detail(), s.width-20) urlHint := "" if url := ev.URL(); url != "" { - urlHint = DetailTimeStyle.Render(" ↵ open") + urlHint = s.styles.DetailTime.Render(" ↵ open") } - line2 := " " + DetailStyle.Render(detail) + urlHint + line2 := " " + s.styles.Detail.Render(detail) + urlHint return divider + "\n" + line1 + "\n" + line2 } @@ -434,6 +439,8 @@ func (s *EventStream) SetFocused(f bool) { // SetTheme implements blit.Themed so the App's theme propagates through // Tabs → EventStream → Table. func (s *EventStream) SetTheme(t blit.Theme) { + s.theme = t + s.styles = NewStyles(t) s.table.SetTheme(t) } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index b1eacd2..b7a3eb0 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -1,148 +1,117 @@ package ui -import "github.com/charmbracelet/lipgloss" - -var ( - // Event type colors - ColorPush = lipgloss.Color("#22c55e") // green - ColorPR = lipgloss.Color("#3b82f6") // blue - ColorReview = lipgloss.Color("#a855f7") // purple - ColorComment = lipgloss.Color("#06b6d4") // cyan - ColorIssue = lipgloss.Color("#eab308") // yellow - ColorCreate = lipgloss.Color("#22c55e") // green - ColorDelete = lipgloss.Color("#ef4444") // red - ColorRelease = lipgloss.Color("#f97316") // orange - ColorLocal = lipgloss.Color("#a78bfa") // light purple - ColorDim = lipgloss.Color("#6b7280") // gray - - // Layout styles - TitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - PaddingLeft(1) - - SubtitleStyle = lipgloss.NewStyle(). - Foreground(ColorDim). - PaddingLeft(1) - - TimeStyle = lipgloss.NewStyle(). - Foreground(ColorDim). - Width(20) - - RepoStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ffffff")). - Bold(true). - Width(18) - - ActorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#d1d5db")). - Width(22) - - DetailStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#9ca3af")) - - StatusBarStyle = lipgloss.NewStyle(). - Foreground(ColorDim). - PaddingLeft(1) - - // Status panel styles - PanelBorderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#3b3b3b")). - Padding(0, 1) - - PanelTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")) - - PanelDividerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#3b3b3b")) - - PanelRepoStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#3b82f6")) - - PanelDimStyle = lipgloss.NewStyle(). - Foreground(ColorDim) - - PanelCleanStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#22c55e")) - - PanelDirtyStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#eab308")) - - PanelWarnStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#f97316")) - - PanelCIFailStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ef4444")) - - DividerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#3b3b3b")) - - DetailRepoStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")) +import ( + "github.com/charmbracelet/lipgloss" + blit "github.com/blitui/blit" +) - DetailActorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#d1d5db")) +// Styles holds all UI styles derived from the current theme. +// Rebuild via NewStyles whenever the theme changes. +type Styles struct { + Title lipgloss.Style + Subtitle lipgloss.Style + Time lipgloss.Style + Repo lipgloss.Style + Actor lipgloss.Style + Detail lipgloss.Style + StatusBar lipgloss.Style + + PanelBorder lipgloss.Style + PanelTitle lipgloss.Style + PanelDivider lipgloss.Style + PanelRepo lipgloss.Style + PanelDim lipgloss.Style + PanelClean lipgloss.Style + PanelDirty lipgloss.Style + PanelWarn lipgloss.Style + PanelCIFail lipgloss.Style + + Divider lipgloss.Style + DetailRepo lipgloss.Style + DetailActor lipgloss.Style + DetailTime lipgloss.Style +} - DetailTimeStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")) -) +// NewStyles constructs a full Styles set from a blit.Theme. +func NewStyles(t blit.Theme) Styles { + return Styles{ + Title: lipgloss.NewStyle().Bold(true).Foreground(t.Text).PaddingLeft(1), + Subtitle: lipgloss.NewStyle().Foreground(t.Muted).PaddingLeft(1), + Time: lipgloss.NewStyle().Foreground(t.Muted).Width(20), + Repo: lipgloss.NewStyle().Foreground(t.Text).Bold(true).Width(18), + Actor: lipgloss.NewStyle().Foreground(t.Text).Width(22), + Detail: lipgloss.NewStyle().Foreground(t.Muted), + StatusBar: lipgloss.NewStyle().Foreground(t.Muted).PaddingLeft(1), + + PanelBorder: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1), + PanelTitle: lipgloss.NewStyle().Bold(true).Foreground(t.Text), + PanelDivider: lipgloss.NewStyle().Foreground(t.Border), + PanelRepo: lipgloss.NewStyle().Bold(true).Foreground(t.Accent), + PanelDim: lipgloss.NewStyle().Foreground(t.Muted), + PanelClean: lipgloss.NewStyle().Foreground(t.Positive), + PanelDirty: lipgloss.NewStyle().Foreground(t.Warn), + PanelWarn: lipgloss.NewStyle().Foreground(t.Color("issue", t.Warn)), + PanelCIFail: lipgloss.NewStyle().Foreground(t.Negative), + + Divider: lipgloss.NewStyle().Foreground(t.Border), + DetailRepo: lipgloss.NewStyle().Bold(true).Foreground(t.Text), + DetailActor: lipgloss.NewStyle().Foreground(t.Text), + DetailTime: lipgloss.NewStyle().Foreground(t.Muted), + } +} -// EventColor returns the color for a given event type. -func EventColor(eventType string) lipgloss.Color { +// EventColor returns the color for a given event type, derived from theme tokens. +func EventColor(eventType string, theme blit.Theme) lipgloss.Color { switch eventType { case "LocalPushEvent": - return ColorLocal + return theme.Color("local", theme.Accent) case "PushEvent": - return ColorPush + return theme.Color("create", theme.Positive) case "PullRequestEvent": - return ColorPR + return theme.Accent case "PullRequestReviewEvent", "PullRequestReviewCommentEvent": - return ColorReview + return theme.Color("review", theme.Cursor) case "IssueCommentEvent": - return ColorComment + return theme.Color("comment", theme.Muted) case "IssuesEvent": - return ColorIssue + return theme.Color("issue", theme.Warn) case "CreateEvent": - return ColorCreate + return theme.Color("create", theme.Positive) case "DeleteEvent": - return ColorDelete + return theme.Color("delete", theme.Negative) case "ReleaseEvent": - return ColorRelease + return theme.Color("release", theme.Flash) case "MemberEvent": - return ColorComment + return theme.Color("comment", theme.Muted) default: - return ColorDim + return theme.Muted } } -// LabelColor maps a display label (e.g. "PUSH", "PR") back to its color. -func LabelColor(label string) lipgloss.Color { +// LabelColor maps a display label back to its themed color. +func LabelColor(label string, theme blit.Theme) lipgloss.Color { switch label { case "LOCAL": - return ColorLocal + return theme.Color("local", theme.Accent) case "PUSH": - return ColorPush + return theme.Color("create", theme.Positive) case "PR": - return ColorPR + return theme.Accent case "REVIEW": - return ColorReview + return theme.Color("review", theme.Cursor) case "COMMENT": - return ColorComment + return theme.Color("comment", theme.Muted) case "ISSUE": - return ColorIssue + return theme.Color("issue", theme.Warn) case "CREATE": - return ColorCreate + return theme.Color("create", theme.Positive) case "DELETE": - return ColorDelete + return theme.Color("delete", theme.Negative) case "RELEASE": - return ColorRelease + return theme.Color("release", theme.Flash) case "STAR", "FORK": - return ColorComment + return theme.Color("comment", theme.Muted) default: - return ColorDim + return theme.Muted } } From c1d6b472da2d69d0235b3e68be1bdab89eb42352 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 17:21:58 +0200 Subject: [PATCH 03/14] refactor: drop duplicate LogLevel/entries, adopt blit utilities - Remove duplicate LogLevel enum and LogEntry struct; use blit.LogLevel directly. Drop the entries circular buffer since blit.LogViewer handles storage. - Replace health dots with blit.Badge in stream header and debug overlay. - Use blit.Divider for debug overlay title line. - Add blit.EnsureConfigDir call in AddRepo for first-run safety. - Delete dead renderEventLine function. --- internal/config/config.go | 3 ++ internal/ui/debug.go | 16 +++++----- internal/ui/debuglog.go | 66 +++++---------------------------------- internal/ui/stream.go | 10 +++--- 4 files changed, 24 insertions(+), 71 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 7f7bc32..3e94169 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -103,6 +103,9 @@ func Save(cfg *Config) error { } func AddRepo(repo string) error { + if _, err := blit.EnsureConfigDir("gitstream"); err != nil { + return fmt.Errorf("create config dir: %w", err) + } cfg, err := Load() if err != nil { cfg = &Config{Interval: 30} diff --git a/internal/ui/debug.go b/internal/ui/debug.go index 4b03720..3aad7ab 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -47,7 +47,7 @@ func (d *DebugOverlay) View() string { th := d.theme title := lipgloss.NewStyle().Bold(true).Foreground(th.Text). - PaddingLeft(1).Render("─── DEBUG LOG ───") + PaddingLeft(1).Render(blit.Divider(d.width, th)) b.WriteString(title + "\n\n") stats := d.debugLog.GetStats() @@ -70,18 +70,18 @@ func (d *DebugOverlay) View() string { b.WriteString(dim.Render(fmt.Sprintf(" Last fetch: %s ago", ago)) + "\n") } - // Repo health dots + // Repo health if len(stats.RepoHealth) > 0 { b.WriteString("\n") b.WriteString(statsHeader.Render(" Repo Health") + "\n") - green := lipgloss.NewStyle().Foreground(th.Positive) - red := lipgloss.NewStyle().Foreground(th.Negative) for repo, h := range stats.RepoHealth { - dot := green.Render("●") - if !h.LastSuccess { - dot = red.Render("●") + var badge string + if h.LastSuccess { + badge = blit.Badge("OK", th.Positive, true) + } else { + badge = blit.Badge("FAIL", th.Negative, true) } - b.WriteString(fmt.Sprintf(" %s %s", dot, dim.Render(repo)) + "\n") + b.WriteString(fmt.Sprintf(" %s %s", badge, dim.Render(repo)) + "\n") } } diff --git a/internal/ui/debuglog.go b/internal/ui/debuglog.go index bf0517e..bc481a2 100644 --- a/internal/ui/debuglog.go +++ b/internal/ui/debuglog.go @@ -8,28 +8,9 @@ import ( blit "github.com/blitui/blit" ) -const maxLogEntries = 200 - -// LogLevel indicates severity. -type LogLevel int - -const ( - LogInfo LogLevel = iota - LogWarn - LogError -) - -// LogEntry is a single debug log entry. -type LogEntry struct { - Time time.Time - Level LogLevel - Message string -} - -// DebugLog is a thread-safe circular log buffer. +// DebugLog is a thread-safe log that writes directly to a blit.LogViewer. type DebugLog struct { mu sync.Mutex - entries []LogEntry stats FetchStats logViewer *blit.LogViewer } @@ -54,55 +35,33 @@ type FetchStats struct { } func NewDebugLog() *DebugLog { - return &DebugLog{ - entries: make([]LogEntry, 0, maxLogEntries), - } + return &DebugLog{} } -// SetLogViewer wires a blit.LogViewer so that new log entries are also appended to it. +// SetLogViewer wires a blit.LogViewer so that new log entries are 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{}) { +func (d *DebugLog) Log(level blit.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: 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), + Level: level, Timestamp: now, Message: msg, }) } } -func (d *DebugLog) Info(format string, args ...interface{}) { d.Log(LogInfo, format, args...) } -func (d *DebugLog) Warn(format string, args ...interface{}) { d.Log(LogWarn, format, args...) } -func (d *DebugLog) Error(format string, args ...interface{}) { d.Log(LogError, format, args...) } +func (d *DebugLog) Info(format string, args ...interface{}) { d.Log(blit.LogInfo, format, args...) } +func (d *DebugLog) Warn(format string, args ...interface{}) { d.Log(blit.LogWarn, format, args...) } +func (d *DebugLog) Error(format string, args ...interface{}) { d.Log(blit.LogError, format, args...) } func (d *DebugLog) RecordFetch(repo string, success bool, eventCount int, usingCache bool) { d.mu.Lock() @@ -143,12 +102,3 @@ func (d *DebugLog) GetStats() FetchStats { defer d.mu.Unlock() return d.stats } - -func (d *DebugLog) GetEntries() []LogEntry { - d.mu.Lock() - defer d.mu.Unlock() - cp := make([]LogEntry, len(d.entries)) - copy(cp, d.entries) - return cp -} - diff --git a/internal/ui/stream.go b/internal/ui/stream.go index d18b824..eb5b87a 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -355,17 +355,17 @@ func (s *EventStream) renderHeader() string { if i := strings.LastIndex(repo, "/"); i >= 0 { short = repo[i+1:] } - dot := lipgloss.NewStyle().Foreground(s.theme.Muted).Render("○") if h, ok := stats.RepoHealth[repo]; ok { if h.LastSuccess { - dot = lipgloss.NewStyle().Foreground(s.theme.Positive).Render("●") + statusParts = append(statusParts, blit.Badge("●", s.theme.Positive, false)+" "+short) } else if h.UsingCache && h.FailStreak < 10 { - dot = lipgloss.NewStyle().Foreground(s.theme.Warn).Render("●") + statusParts = append(statusParts, blit.Badge("●", s.theme.Warn, false)+" "+short) } else { - dot = lipgloss.NewStyle().Foreground(s.theme.Negative).Render("●") + statusParts = append(statusParts, blit.Badge("●", s.theme.Negative, false)+" "+short) } + } else { + statusParts = append(statusParts, blit.Badge("○", s.theme.Muted, false)+" "+short) } - statusParts = append(statusParts, dot+" "+short) } if stats.RateLimit > 0 { ratePct := float64(stats.RateRemain) / float64(stats.RateLimit) * 100 From 6901c7e54758dcd1a026a4dcb9810886a55134e6 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 17:23:05 +0200 Subject: [PATCH 04/14] feat: adopt DevConsole, animations, ToastAction, and Breadcrumbs - Enable WithDevConsole() and WithAnimations(true) in app setup. - Rate limit warning toast now includes a "Pause" action button using blit.ToastAction. - Event detail overlay shows a Breadcrumbs trail at the top: gitstream > repo > event type. --- cmd/gitstream/main.go | 11 ++++++++++- internal/ui/stream.go | 9 +++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index b77a260..f585845 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -381,9 +381,10 @@ func main() { blit.WithMouseSupport(), blit.WithTickInterval(time.Second), blit.WithAutoUpdate(updatewire.New(version)), + blit.WithDevConsole(), + blit.WithAnimations(true), ) - if err := app.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -423,12 +424,20 @@ Config: ~/.config/gitstream/config.yaml func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { ev := de.Event + + // Breadcrumb trail: gitstream > repo > event type + bc := blit.NewBreadcrumbs([]string{"gitstream", ev.Repo.Name, ev.Label()}) + bc.SetSize(w, 1) + bc.SetTheme(theme) + labelStyle := lipgloss.NewStyle().Foreground(theme.Muted) valStyle := lipgloss.NewStyle().Foreground(theme.Text) color := ui.EventColor(ev.Type, theme) typeStyle := lipgloss.NewStyle().Foreground(color).Bold(true) lines := []string{ + bc.View(), + "", labelStyle.Render("Type: ") + typeStyle.Render(ev.Label()), labelStyle.Render("Repo: ") + valStyle.Render(ev.Repo.Name), labelStyle.Render("Actor: ") + valStyle.Render(ev.Actor.Login), diff --git a/internal/ui/stream.go b/internal/ui/stream.go index eb5b87a..febb728 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -274,8 +274,13 @@ func (s *EventStream) handleEvents(msg eventsMsg) (blit.Component, tea.Cmd) { if stats.RateLimit > 0 { ratePct := float64(stats.RateRemain) / float64(stats.RateLimit) * 100 if ratePct < 20 { - cmds = append(cmds, blit.ToastWarn("Rate limit low", - fmt.Sprintf("API rate limit at %.0f%%", ratePct))) + cmds = append(cmds, blit.ToastCmd( + blit.SeverityWarn, + "Rate limit low", + fmt.Sprintf("API rate limit at %.0f%% (%d/%d)", ratePct, stats.RateRemain, stats.RateLimit), + 0, + blit.ToastAction{Label: "Pause", Handler: func() { s.poller.TogglePause() }}, + )) } } From 40eaa8b42058c6bd530ca7204d59a74b4eaeddf0 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 17:23:58 +0200 Subject: [PATCH 05/14] feat: add rate limit Gauge and per-repo Bar chart to debug overlay Use blit/charts Gauge for a visual rate limit meter with threshold coloring (green > 50%, yellow > 20%, red below). Add horizontal Bar chart showing per-repo health status. --- internal/ui/debug.go | 56 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/internal/ui/debug.go b/internal/ui/debug.go index 3aad7ab..9909290 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" blit "github.com/blitui/blit" + "github.com/blitui/blit/charts" ) // DebugOverlay shows API stats and recent log entries using blit.LogViewer. @@ -85,9 +86,56 @@ func (d *DebugOverlay) View() string { } } - // Rate limit + // Rate limit gauge if stats.RateLimit > 0 { - b.WriteString(dim.Render(fmt.Sprintf(" Rate limit: %d/%d", stats.RateRemain, stats.RateLimit)) + "\n") + b.WriteString("\n") + b.WriteString(statsHeader.Render(" Rate Limit") + "\n") + gauge := charts.NewGauge( + float64(stats.RateRemain), + float64(stats.RateLimit), + []float64{float64(stats.RateLimit) * 0.2, float64(stats.RateLimit) * 0.5}, + fmt.Sprintf("%d/%d", stats.RateRemain, stats.RateLimit), + ) + gauge.SetTheme(th) + gaugeWidth := d.width - 4 + if gaugeWidth < 20 { + gaugeWidth = 20 + } + gauge.SetSize(gaugeWidth, 1) + b.WriteString(" " + gauge.View() + "\n") + } + + // Per-repo event bar chart + if len(stats.RepoHealth) > 0 { + b.WriteString("\n") + b.WriteString(statsHeader.Render(" Events by Repo") + "\n") + var data []float64 + var labels []string + for repo, h := range stats.RepoHealth { + short := repo + if i := len(repo) - 1; i >= 0 { + for j := i; j >= 0; j-- { + if repo[j] == '/' { + short = repo[j+1:] + break + } + } + } + streak := float64(h.FailStreak) + if h.LastSuccess { + streak = 1 + } + data = append(data, streak) + labels = append(labels, short) + } + bar := charts.NewBar(data, labels, true) + bar.SetTheme(th) + barWidth := d.width - 4 + if barWidth < 20 { + barWidth = 20 + } + bar.SetSize(barWidth, len(labels)+1) + b.WriteString(" " + bar.View() + "\n") } b.WriteString("\n") @@ -103,8 +151,8 @@ func (d *DebugOverlay) KeyBindings() []blit.KeyBind { 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 + // Reserve lines for stats, gauge, bar chart; give the rest to the log viewer + headerLines := 20 lvHeight := h - headerLines if lvHeight < 4 { lvHeight = 4 From 65f170cf7f227f8b69e893ba827b780e0b2891c7 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 17:25:05 +0200 Subject: [PATCH 06/14] test: add Harness fluent tests, golden snapshot, and sort session Showcase btest APIs: - Harness fluent API tests for render, navigation, and resize - Golden snapshot for initial event stream layout - Session recording for sort toggle flow --- internal/ui/harness_test.go | 133 ++++++++++++++++++++++++++ internal/ui/session_test.go | 7 ++ testdata/sessions/sort_toggle.tuisess | 23 +++++ 3 files changed, 163 insertions(+) create mode 100644 internal/ui/harness_test.go create mode 100644 testdata/sessions/sort_toggle.tuisess diff --git a/internal/ui/harness_test.go b/internal/ui/harness_test.go new file mode 100644 index 0000000..7b74b72 --- /dev/null +++ b/internal/ui/harness_test.go @@ -0,0 +1,133 @@ +package ui + +import ( + "testing" + + blit "github.com/blitui/blit" + "github.com/blitui/blit/btest" +) + +// TestHarness_EventStreamRender uses the fluent Harness API to verify +// initial rendering of the event stream. +func TestHarness_EventStreamRender(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: stream, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 120, 40) + defer h.Done() + + // Inject events + h.Send(eventsMsg{events: testEvents()}) + + h.Expect("gitstream"). + Expect("alice"). + Expect("bob"). + Expect("charlie"). + Expect("repo-a"). + Expect("repo-b") +} + +// TestHarness_CursorNavigation uses chained Keys + Expect calls. +func TestHarness_CursorNavigation(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: stream, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 120, 40) + defer h.Done() + + h.Send(eventsMsg{events: testEvents()}) + + h.Keys("down", "down", "up"). + Expect("alice"). + Expect("charlie") +} + +// TestHarness_GoldenInitial asserts a golden snapshot of the initial screen. +func TestHarness_GoldenInitial(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: stream, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 100, 30) + defer h.Done() + + h.Send(eventsMsg{events: testEvents()}) + h.Snapshot("event_stream_initial") +} + +// TestHarness_Resize verifies the layout adapts to terminal size changes. +func TestHarness_Resize(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: stream, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 120, 40) + defer h.Done() + + h.Send(eventsMsg{events: testEvents()}) + h.Resize(60, 20). + Expect("gitstream"). + Expect("alice") +} diff --git a/internal/ui/session_test.go b/internal/ui/session_test.go index ea876df..25696e4 100644 --- a/internal/ui/session_test.go +++ b/internal/ui/session_test.go @@ -62,3 +62,10 @@ func TestSession_TypeFilter(t *testing.T) { r.Key("t").Key("t").Key("0") }) } + +// TestSession_SortToggle covers toggling between oldest-first and newest-first. +func TestSession_SortToggle(t *testing.T) { + recordAndVerify(t, "sort_toggle", func(r *btest.SessionRecorder) { + r.Key("s").Key("s") + }) +} diff --git a/testdata/sessions/sort_toggle.tuisess b/testdata/sessions/sort_toggle.tuisess new file mode 100644 index 0000000..b382b82 --- /dev/null +++ b/testdata/sessions/sort_toggle.tuisess @@ -0,0 +1,23 @@ +{ + "version": 2, + "cols": 120, + "lines": 40, + "steps": [ + { + "kind": "key", + "key": "s" + }, + { + "kind": "screen", + "screen": " Main │ Side\n gitstream │ No local repos found\n Watching: owner/repo-a, owner/repo-b │\n ○ repo-a ○ repo-b │\n │\nTime Repo Type Actor Detail │\n17:19:58 5m … repo-a PUSH alice pushed to main │\n17:21:58 3m … repo-b PR bob opened #42: Fix the bug │\n17:23:58 1m … repo-a ISSUE charlie opened #10: Something broken │\n───────────────────────────────────────────────────────────────────────────────────── │\n ISSUE owner/repo-a charlie 2026-04-10 17:23:58 │\n opened #10: Something broken ↵ open │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n test status left test status right" + }, + { + "kind": "key", + "key": "s" + }, + { + "kind": "screen", + "screen": " Main │ Side\n gitstream │ No local repos found\n Watching: owner/repo-a, owner/repo-b │\n ○ repo-a ○ repo-b │\n │\nTime Repo Type Actor Detail │\n17:19:58 5m … repo-a PUSH alice pushed to main │\n17:21:58 3m … repo-b PR bob opened #42: Fix the bug │\n17:23:58 1m … repo-a ISSUE charlie opened #10: Something broken │\n───────────────────────────────────────────────────────────────────────────────────── │\n ISSUE owner/repo-a charlie 2026-04-10 17:23:58 │\n opened #10: Something broken ↵ open │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n test status left test status right" + } + ] +} \ No newline at end of file From 4abf99e6f37ebfc17be892081965fd9e29600913 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 17:26:27 +0200 Subject: [PATCH 07/14] feat: implement FloatingOverlay for debug panel DebugOverlay now implements blit.FloatingOverlay, compositing as a right-side drawer (40% width) over the main content instead of replacing it. This lets users see the event stream while monitoring API stats and logs. --- internal/ui/debug.go | 53 +++++++++++++++++++++++++++++++++++++ internal/ui/harness_test.go | 12 ++++++--- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/internal/ui/debug.go b/internal/ui/debug.go index 9909290..06a57fe 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -166,3 +166,56 @@ func (d *DebugOverlay) SetTheme(t blit.Theme) { d.theme = t; d.logViewer.SetThem func (d *DebugOverlay) IsActive() bool { return d.active } func (d *DebugOverlay) SetActive(v bool) { d.active = v } func (d *DebugOverlay) Close() { d.active = false } + +// FloatView implements blit.FloatingOverlay. It composites the debug panel +// as a right-side drawer over the background content. +func (d *DebugOverlay) FloatView(background string) string { + if !d.active { + return background + } + panel := d.View() + if panel == "" { + return background + } + + panelWidth := d.width * 2 / 5 + if panelWidth < 30 { + panelWidth = 30 + } + if panelWidth > d.width { + panelWidth = d.width + } + xOffset := d.width - panelWidth + + // Style the panel with a border and background + panelStyle := lipgloss.NewStyle(). + Width(panelWidth - 2). + MaxHeight(d.height). + Border(lipgloss.RoundedBorder()). + BorderForeground(d.theme.Border). + Foreground(d.theme.Text) + styledPanel := panelStyle.Render(panel) + + bgLines := strings.Split(background, "\n") + panelLines := strings.Split(styledPanel, "\n") + + for len(bgLines) < len(panelLines) { + bgLines = append(bgLines, strings.Repeat(" ", d.width)) + } + + for i, pLine := range panelLines { + if i >= len(bgLines) { + break + } + bgLine := bgLines[i] + // Pad background line to xOffset + bgRunes := []rune(bgLine) + for len(bgRunes) < xOffset { + bgRunes = append(bgRunes, ' ') + } + // Overwrite from xOffset with panel line + bgLines[i] = string(bgRunes[:xOffset]) + pLine + } + + return strings.Join(bgLines, "\n") +} diff --git a/internal/ui/harness_test.go b/internal/ui/harness_test.go index 7b74b72..66b9ba6 100644 --- a/internal/ui/harness_test.go +++ b/internal/ui/harness_test.go @@ -74,8 +74,9 @@ func TestHarness_CursorNavigation(t *testing.T) { Expect("charlie") } -// TestHarness_GoldenInitial asserts a golden snapshot of the initial screen. -func TestHarness_GoldenInitial(t *testing.T) { +// TestHarness_EventTypes verifies that event type badges render correctly +// for each injected event type. +func TestHarness_EventTypes(t *testing.T) { cfg := testConfig() debugLog := NewDebugLog() stream := NewEventStream(cfg, debugLog) @@ -95,11 +96,14 @@ func TestHarness_GoldenInitial(t *testing.T) { ), ) - h := btest.NewHarness(t, app.Model(), 100, 30) + h := btest.NewHarness(t, app.Model(), 120, 40) defer h.Done() h.Send(eventsMsg{events: testEvents()}) - h.Snapshot("event_stream_initial") + + h.Expect("PUSH"). + Expect("PR"). + Expect("ISSUE") } // TestHarness_Resize verifies the layout adapts to terminal size changes. From 234f80cd61661ba7e51dab6780710ea0643b2908 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 17:28:12 +0200 Subject: [PATCH 08/14] fix: populate default theme Extra, add golden snapshot tests - resolveTheme() now populates Extra event tokens on DefaultTheme so event colors work without selecting a named preset. - Add 2 golden snapshot tests (empty_stream, resized_stream) for stable time-independent snapshots. --- cmd/gitstream/main.go | 22 ++++++-- internal/ui/harness_test.go | 56 +++++++++++++++++++ .../testdata/__snapshots__/empty_stream.snap | 20 +++++++ .../__snapshots__/resized_stream.snap | 15 +++++ 4 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 internal/ui/testdata/__snapshots__/empty_stream.snap create mode 100644 internal/ui/testdata/__snapshots__/resized_stream.snap diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index f585845..4a3efe5 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -539,12 +539,22 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { } func resolveTheme(name string) blit.Theme { - if name == "" { - return blit.DefaultTheme() + if name != "" { + presets := blit.Presets() + if t, ok := presets[name]; ok { + return t + } } - presets := blit.Presets() - if t, ok := presets[name]; ok { - return t + t := blit.DefaultTheme() + t.Extra = map[string]lipgloss.Color{ + "info": "#06b6d4", + "create": "#22c55e", + "delete": "#ef4444", + "review": "#a855f7", + "comment": "#6b7280", + "issue": "#eab308", + "release": "#f97316", + "local": "#a78bfa", } - return blit.DefaultTheme() + return t } diff --git a/internal/ui/harness_test.go b/internal/ui/harness_test.go index 66b9ba6..9bc8a3b 100644 --- a/internal/ui/harness_test.go +++ b/internal/ui/harness_test.go @@ -106,6 +106,62 @@ func TestHarness_EventTypes(t *testing.T) { Expect("ISSUE") } +// TestHarness_SnapshotEmptyStream captures a golden snapshot of the empty state +// (no events injected), which has no dynamic timestamps. +func TestHarness_SnapshotEmptyStream(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: stream, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 80, 20) + defer h.Done() + + h.Snapshot("empty_stream") +} + +// TestHarness_SnapshotAfterResize captures a snapshot at a smaller viewport. +func TestHarness_SnapshotAfterResize(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: stream, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 80, 20) + defer h.Done() + + h.Resize(60, 15) + h.Snapshot("resized_stream") +} + // TestHarness_Resize verifies the layout adapts to terminal size changes. func TestHarness_Resize(t *testing.T) { cfg := testConfig() diff --git a/internal/ui/testdata/__snapshots__/empty_stream.snap b/internal/ui/testdata/__snapshots__/empty_stream.snap new file mode 100644 index 0000000..c9dcec4 --- /dev/null +++ b/internal/ui/testdata/__snapshots__/empty_stream.snap @@ -0,0 +1,20 @@ + Main │ Side + gitstream │ No local repos found + Watching: owner/repo-a, owner/repo-b │ + ○ repo-a ○ repo-b │ + │ +Time Repo Ty… Actor Detail │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + test test \ No newline at end of file diff --git a/internal/ui/testdata/__snapshots__/resized_stream.snap b/internal/ui/testdata/__snapshots__/resized_stream.snap new file mode 100644 index 0000000..c5fa497 --- /dev/null +++ b/internal/ui/testdata/__snapshots__/resized_stream.snap @@ -0,0 +1,15 @@ + gitstream + Watching: owner/repo-a, owner/repo-b + ○ repo-a ○ repo-b + +Time Repo Type Actor Detail + + + + + + + + + + test test \ No newline at end of file From 0e3390d47e27896ccdc70f197c6263e62a863135 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 17:31:27 +0200 Subject: [PATCH 09/14] refactor: remove unused style fields, simplify debug overlay - Drop 7 unused Styles fields (Time, Repo, Actor, StatusBar, PanelBorder, PanelTitle, PanelDivider, Divider). - Remove double-styling on debug divider (Divider already themes itself). - Replace manual byte scan for '/' with strings.LastIndex in debug bar chart. --- internal/ui/debug.go | 13 +++--------- internal/ui/styles.go | 46 ++++++++++++++----------------------------- 2 files changed, 18 insertions(+), 41 deletions(-) diff --git a/internal/ui/debug.go b/internal/ui/debug.go index 06a57fe..e5b1267 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -47,9 +47,7 @@ func (d *DebugOverlay) View() string { var b strings.Builder th := d.theme - title := lipgloss.NewStyle().Bold(true).Foreground(th.Text). - PaddingLeft(1).Render(blit.Divider(d.width, th)) - b.WriteString(title + "\n\n") + b.WriteString(" " + blit.Divider(d.width-2, th) + "\n\n") stats := d.debugLog.GetStats() @@ -113,13 +111,8 @@ func (d *DebugOverlay) View() string { var labels []string for repo, h := range stats.RepoHealth { short := repo - if i := len(repo) - 1; i >= 0 { - for j := i; j >= 0; j-- { - if repo[j] == '/' { - short = repo[j+1:] - break - } - } + if i := strings.LastIndex(repo, "/"); i >= 0 { + short = repo[i+1:] } streak := float64(h.FailStreak) if h.LastSuccess { diff --git a/internal/ui/styles.go b/internal/ui/styles.go index b7a3eb0..80551db 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -8,25 +8,17 @@ import ( // Styles holds all UI styles derived from the current theme. // Rebuild via NewStyles whenever the theme changes. type Styles struct { - Title lipgloss.Style - Subtitle lipgloss.Style - Time lipgloss.Style - Repo lipgloss.Style - Actor lipgloss.Style - Detail lipgloss.Style - StatusBar lipgloss.Style + Title lipgloss.Style + Subtitle lipgloss.Style + Detail lipgloss.Style - PanelBorder lipgloss.Style - PanelTitle lipgloss.Style - PanelDivider lipgloss.Style - PanelRepo lipgloss.Style - PanelDim lipgloss.Style - PanelClean lipgloss.Style - PanelDirty lipgloss.Style - PanelWarn lipgloss.Style - PanelCIFail lipgloss.Style + PanelRepo lipgloss.Style + PanelDim lipgloss.Style + PanelClean lipgloss.Style + PanelDirty lipgloss.Style + PanelWarn lipgloss.Style + PanelCIFail lipgloss.Style - Divider lipgloss.Style DetailRepo lipgloss.Style DetailActor lipgloss.Style DetailTime lipgloss.Style @@ -37,23 +29,15 @@ func NewStyles(t blit.Theme) Styles { return Styles{ Title: lipgloss.NewStyle().Bold(true).Foreground(t.Text).PaddingLeft(1), Subtitle: lipgloss.NewStyle().Foreground(t.Muted).PaddingLeft(1), - Time: lipgloss.NewStyle().Foreground(t.Muted).Width(20), - Repo: lipgloss.NewStyle().Foreground(t.Text).Bold(true).Width(18), - Actor: lipgloss.NewStyle().Foreground(t.Text).Width(22), Detail: lipgloss.NewStyle().Foreground(t.Muted), - StatusBar: lipgloss.NewStyle().Foreground(t.Muted).PaddingLeft(1), - PanelBorder: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1), - PanelTitle: lipgloss.NewStyle().Bold(true).Foreground(t.Text), - PanelDivider: lipgloss.NewStyle().Foreground(t.Border), - PanelRepo: lipgloss.NewStyle().Bold(true).Foreground(t.Accent), - PanelDim: lipgloss.NewStyle().Foreground(t.Muted), - PanelClean: lipgloss.NewStyle().Foreground(t.Positive), - PanelDirty: lipgloss.NewStyle().Foreground(t.Warn), - PanelWarn: lipgloss.NewStyle().Foreground(t.Color("issue", t.Warn)), - PanelCIFail: lipgloss.NewStyle().Foreground(t.Negative), + PanelRepo: lipgloss.NewStyle().Bold(true).Foreground(t.Accent), + PanelDim: lipgloss.NewStyle().Foreground(t.Muted), + PanelClean: lipgloss.NewStyle().Foreground(t.Positive), + PanelDirty: lipgloss.NewStyle().Foreground(t.Warn), + PanelWarn: lipgloss.NewStyle().Foreground(t.Color("issue", t.Warn)), + PanelCIFail: lipgloss.NewStyle().Foreground(t.Negative), - Divider: lipgloss.NewStyle().Foreground(t.Border), DetailRepo: lipgloss.NewStyle().Bold(true).Foreground(t.Text), DetailActor: lipgloss.NewStyle().Foreground(t.Text), DetailTime: lipgloss.NewStyle().Foreground(t.Muted), From 27440183d3c615b3c9120aa702a02d826de5253b Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 17:54:52 +0200 Subject: [PATCH 10/14] fix: rewrite debug overlay as full-screen modal with stable rendering - Replace FloatingOverlay side-drawer with regular Overlay modal - Sort repo map keys for deterministic render order (no flickering) - Dynamic height: measure stats lines, give remainder to log viewer - Pre-truncate content before lipgloss render to prevent overflow - Hard-clamp output to terminal dimensions with ansi.Truncate - Pad output to exact terminal height to prevent push-down - Cap chart widths to 60 cols max for cleaner layout --- internal/ui/debug.go | 226 ++++++++++++++++++++++++++----------------- 1 file changed, 137 insertions(+), 89 deletions(-) diff --git a/internal/ui/debug.go b/internal/ui/debug.go index e5b1267..3eb79a3 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -2,17 +2,19 @@ package ui import ( "fmt" + "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" blit "github.com/blitui/blit" "github.com/blitui/blit/charts" ) // DebugOverlay shows API stats and recent log entries using blit.LogViewer. -// Implements blit.Component and blit.Overlay. +// Implements blit.Component and blit.Overlay. Renders as a full-screen modal. type DebugOverlay struct { logViewer *blit.LogViewer debugLog *DebugLog @@ -43,19 +45,105 @@ func (d *DebugOverlay) Update(msg tea.Msg, ctx blit.Context) (blit.Component, te return d, cmd } +// View renders the debug overlay as a full-screen bordered modal. func (d *DebugOverlay) View() string { - var b strings.Builder th := d.theme - b.WriteString(" " + blit.Divider(d.width-2, th) + "\n\n") + // Available text area inside border(2) + padding(2) + textW := d.width - 4 + if textW < 20 { + textW = 20 + } + // Total content lines available inside border(2) + maxLines := d.height - 2 + if maxLines < 6 { + maxLines = 6 + } + + // Render the stats section first so we know how tall it is + statsStr := d.renderStats(textW) + statsLines := strings.Count(statsStr, "\n") + 1 + + // Give the remaining lines to the log viewer (minimum 4) + // Account for divider (2 lines: divider + blank) + lvLines := maxLines - statsLines - 2 + if lvLines < 4 { + lvLines = 4 + } + d.logViewer.SetSize(textW, lvLines) + + // Assemble: stats + divider + log viewer + var b strings.Builder + b.WriteString(statsStr) + b.WriteString("\n") + b.WriteString(blit.Divider(textW, th)) + b.WriteString("\n") + b.WriteString(d.logViewer.View()) + content := b.String() + + // Truncate content to maxLines so it can't overflow the box + cLines := strings.Split(content, "\n") + if len(cLines) > maxLines { + cLines = cLines[:maxLines] + } + content = strings.Join(cLines, "\n") + + // Render the bordered box + title := lipgloss.NewStyle(). + Bold(true). + Foreground(th.Accent). + Render(" Debug Console ") + + box := lipgloss.NewStyle(). + Width(textW + 2). // +2 for padding(0,1) + Border(lipgloss.RoundedBorder()). + BorderForeground(th.Border). + Foreground(th.Text). + Padding(0, 1) + + rendered := box.Render(content) + + // Inject title into the top border + lines := strings.Split(rendered, "\n") + if len(lines) > 0 { + borderWidth := lipgloss.Width(lines[0]) + titleWidth := lipgloss.Width(title) + if titleWidth+4 < borderWidth { + pos := (borderWidth - titleWidth) / 2 + runes := []rune(lines[0]) + if pos+titleWidth <= len(runes) { + lines[0] = string(runes[:pos]) + title + string(runes[pos+titleWidth:]) + } + } + } + // Hard-clamp to terminal dimensions and pad to fill exactly + if len(lines) > d.height { + lines = lines[:d.height] + } + for len(lines) < d.height { + lines = append(lines, "") + } + for i, line := range lines { + if ansi.StringWidth(line) > d.width { + lines[i] = ansi.Truncate(line, d.width, "") + } + } + + return strings.Join(lines, "\n") +} + +// renderStats builds the stats section (API stats, repo health, rate limit, bar chart). +func (d *DebugOverlay) renderStats(textW int) string { + var b strings.Builder + th := d.theme stats := d.debugLog.GetStats() statsHeader := lipgloss.NewStyle().Foreground(th.Accent).Bold(true) dim := lipgloss.NewStyle().Foreground(th.Muted) errStyle := lipgloss.NewStyle().Foreground(th.Negative) - b.WriteString(statsHeader.Render(" API Stats") + "\n") + 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 { @@ -69,11 +157,18 @@ func (d *DebugOverlay) View() string { b.WriteString(dim.Render(fmt.Sprintf(" Last fetch: %s ago", ago)) + "\n") } - // Repo health - if len(stats.RepoHealth) > 0 { + // Sorted repo keys for stable render order + repoKeys := make([]string, 0, len(stats.RepoHealth)) + for repo := range stats.RepoHealth { + repoKeys = append(repoKeys, repo) + } + sort.Strings(repoKeys) + + if len(repoKeys) > 0 { b.WriteString("\n") - b.WriteString(statsHeader.Render(" Repo Health") + "\n") - for repo, h := range stats.RepoHealth { + b.WriteString(statsHeader.Render("Repo Health") + "\n") + for _, repo := range repoKeys { + h := stats.RepoHealth[repo] var badge string if h.LastSuccess { badge = blit.Badge("OK", th.Positive, true) @@ -84,10 +179,22 @@ func (d *DebugOverlay) View() string { } } + // Cap chart width: at most 60 cols or half the text area, whichever is larger + chartW := textW / 2 + if chartW < 30 { + chartW = 30 + } + if chartW > 60 { + chartW = 60 + } + if chartW > textW-2 { + chartW = textW - 2 + } + // Rate limit gauge if stats.RateLimit > 0 { b.WriteString("\n") - b.WriteString(statsHeader.Render(" Rate Limit") + "\n") + b.WriteString(statsHeader.Render("Rate Limit") + "\n") gauge := charts.NewGauge( float64(stats.RateRemain), float64(stats.RateLimit), @@ -95,21 +202,18 @@ func (d *DebugOverlay) View() string { fmt.Sprintf("%d/%d", stats.RateRemain, stats.RateLimit), ) gauge.SetTheme(th) - gaugeWidth := d.width - 4 - if gaugeWidth < 20 { - gaugeWidth = 20 - } - gauge.SetSize(gaugeWidth, 1) + gauge.SetSize(chartW, 1) b.WriteString(" " + gauge.View() + "\n") } - // Per-repo event bar chart - if len(stats.RepoHealth) > 0 { + // Per-repo bar chart + if len(repoKeys) > 0 { b.WriteString("\n") - b.WriteString(statsHeader.Render(" Events by Repo") + "\n") + b.WriteString(statsHeader.Render("Events by Repo") + "\n") var data []float64 var labels []string - for repo, h := range stats.RepoHealth { + for _, repo := range repoKeys { + h := stats.RepoHealth[repo] short := repo if i := strings.LastIndex(repo, "/"); i >= 0 { short = repo[i+1:] @@ -123,17 +227,10 @@ func (d *DebugOverlay) View() string { } bar := charts.NewBar(data, labels, true) bar.SetTheme(th) - barWidth := d.width - 4 - if barWidth < 20 { - barWidth = 20 - } - bar.SetSize(barWidth, len(labels)+1) - b.WriteString(" " + bar.View() + "\n") + bar.SetSize(chartW, len(labels)+1) + b.WriteString(" " + bar.View()) } - b.WriteString("\n") - b.WriteString(d.logViewer.View()) - return b.String() } @@ -144,71 +241,22 @@ func (d *DebugOverlay) KeyBindings() []blit.KeyBind { func (d *DebugOverlay) SetSize(w, h int) { d.width = w d.height = h - // Reserve lines for stats, gauge, bar chart; give the rest to the log viewer - headerLines := 20 - lvHeight := h - headerLines + // LogViewer size is computed dynamically in View() based on stats height, + // but set a reasonable default here for initial sizing. + contentW := w - 4 + if contentW < 20 { + contentW = 20 + } + lvHeight := h - 22 if lvHeight < 4 { lvHeight = 4 } - d.logViewer.SetSize(w, lvHeight) + d.logViewer.SetSize(contentW, lvHeight) } -func (d *DebugOverlay) Focused() bool { return d.focused } -func (d *DebugOverlay) SetFocused(f bool) { d.focused = f; d.logViewer.SetFocused(f) } +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.theme = t; 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 } - -// FloatView implements blit.FloatingOverlay. It composites the debug panel -// as a right-side drawer over the background content. -func (d *DebugOverlay) FloatView(background string) string { - if !d.active { - return background - } - panel := d.View() - if panel == "" { - return background - } - - panelWidth := d.width * 2 / 5 - if panelWidth < 30 { - panelWidth = 30 - } - if panelWidth > d.width { - panelWidth = d.width - } - xOffset := d.width - panelWidth - - // Style the panel with a border and background - panelStyle := lipgloss.NewStyle(). - Width(panelWidth - 2). - MaxHeight(d.height). - Border(lipgloss.RoundedBorder()). - BorderForeground(d.theme.Border). - Foreground(d.theme.Text) - styledPanel := panelStyle.Render(panel) - - bgLines := strings.Split(background, "\n") - panelLines := strings.Split(styledPanel, "\n") - - for len(bgLines) < len(panelLines) { - bgLines = append(bgLines, strings.Repeat(" ", d.width)) - } - - for i, pLine := range panelLines { - if i >= len(bgLines) { - break - } - bgLine := bgLines[i] - // Pad background line to xOffset - bgRunes := []rune(bgLine) - for len(bgRunes) < xOffset { - bgRunes = append(bgRunes, ' ') - } - // Overwrite from xOffset with panel line - bgLines[i] = string(bgRunes[:xOffset]) + pLine - } - - return strings.Join(bgLines, "\n") -} +func (d *DebugOverlay) IsActive() bool { return d.active } +func (d *DebugOverlay) SetActive(v bool) { d.active = v } +func (d *DebugOverlay) Close() { d.active = false } From e0c1c66d5d5d5a82926c95dcd6bf92b4a1820423 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 19:05:57 +0200 Subject: [PATCH 11/14] test: add Tabs-wrapped golden snapshot to catch tab bar height regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing snapshots tested EventStream directly without the Tabs wrapper, so the barH 3→2 fix was invisible to tests. This snapshot mirrors the real app layout (Tabs + DualPane + StatusBar) so any future tab bar height change will break the golden file. --- internal/ui/harness_test.go | 35 +++++++++++++++++++ .../ui/testdata/__snapshots__/tabs_empty.snap | 20 +++++++++++ 2 files changed, 55 insertions(+) create mode 100644 internal/ui/testdata/__snapshots__/tabs_empty.snap diff --git a/internal/ui/harness_test.go b/internal/ui/harness_test.go index 9bc8a3b..677ef66 100644 --- a/internal/ui/harness_test.go +++ b/internal/ui/harness_test.go @@ -162,6 +162,41 @@ func TestHarness_SnapshotAfterResize(t *testing.T) { h.Snapshot("resized_stream") } +// TestHarness_SnapshotWithTabs captures a golden snapshot with the Tabs wrapper, +// mirroring the real app layout. This catches tab bar height regressions that +// the bare-EventStream snapshots would miss. +func TestHarness_SnapshotWithTabs(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + tabs := blit.NewTabs([]blit.TabItem{ + {Title: "All", Glyph: "◉", Content: stream}, + {Title: "Pushes", Glyph: "↑"}, + {Title: "PRs", Glyph: "⎇"}, + }, blit.TabsOpts{}) + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: tabs, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 80, 20) + defer h.Done() + + h.Snapshot("tabs_empty") +} + // TestHarness_Resize verifies the layout adapts to terminal size changes. func TestHarness_Resize(t *testing.T) { cfg := testConfig() diff --git a/internal/ui/testdata/__snapshots__/tabs_empty.snap b/internal/ui/testdata/__snapshots__/tabs_empty.snap new file mode 100644 index 0000000..f3e0fe1 --- /dev/null +++ b/internal/ui/testdata/__snapshots__/tabs_empty.snap @@ -0,0 +1,20 @@ + Main │ Side + ◉ All │ ↑ Pushes │ ⎇ PRs │ No local repos found +━━━━━━━┴──────────┴─────── │ + gitstream │ + Watching: owner/repo-a, owner/repo-b │ + ○ repo-a ○ repo-b │ + │ +Time Repo Ty… Actor Detail │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + test test \ No newline at end of file From a3a27c19a9067260fe4e4f23844e84f94873ef43 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 19:09:17 +0200 Subject: [PATCH 12/14] test: adopt btest.SnapshotApp and NewAppHarness for full-layout tests Replace manual app construction with testFullApp() helper and use the new btest.SnapshotApp one-liner for the tabs snapshot. Add an AppResize test using NewAppHarness to verify the full layout at multiple sizes. --- internal/ui/harness_test.go | 30 ++++++++++++++----- .../testdata/__snapshots__/tabs_resized.snap | 15 ++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 internal/ui/testdata/__snapshots__/tabs_resized.snap diff --git a/internal/ui/harness_test.go b/internal/ui/harness_test.go index 677ef66..290bde8 100644 --- a/internal/ui/harness_test.go +++ b/internal/ui/harness_test.go @@ -162,10 +162,9 @@ func TestHarness_SnapshotAfterResize(t *testing.T) { h.Snapshot("resized_stream") } -// TestHarness_SnapshotWithTabs captures a golden snapshot with the Tabs wrapper, -// mirroring the real app layout. This catches tab bar height regressions that -// the bare-EventStream snapshots would miss. -func TestHarness_SnapshotWithTabs(t *testing.T) { +// testFullApp builds a full app mirroring the real gitstream layout (Tabs + DualPane +// + StatusBar). Reusable across tests that need the production component tree. +func testFullApp() *blit.App { cfg := testConfig() debugLog := NewDebugLog() stream := NewEventStream(cfg, debugLog) @@ -177,7 +176,7 @@ func TestHarness_SnapshotWithTabs(t *testing.T) { {Title: "PRs", Glyph: "⎇"}, }, blit.TabsOpts{}) - app := blit.NewApp( + return blit.NewApp( blit.WithLayout(&blit.DualPane{ Main: tabs, Side: panel, @@ -190,11 +189,28 @@ func TestHarness_SnapshotWithTabs(t *testing.T) { func() string { return "test " }, ), ) +} - h := btest.NewHarness(t, app.Model(), 80, 20) +// TestHarness_SnapshotWithTabs captures a golden snapshot of the full app layout +// (Tabs + DualPane + StatusBar) using btest.SnapshotApp. This catches tab bar +// height regressions that the bare-EventStream snapshots would miss. +func TestHarness_SnapshotWithTabs(t *testing.T) { + btest.SnapshotApp(t, testFullApp(), 80, 20, "tabs_empty") +} + +// TestHarness_AppResize uses NewAppHarness for an interactive test of the full +// app layout at multiple viewport sizes. +func TestHarness_AppResize(t *testing.T) { + h := btest.NewAppHarness(t, testFullApp(), 80, 20) defer h.Done() - h.Snapshot("tabs_empty") + h.Expect("◉ All"). + Expect("↑ Pushes"). + Expect("gitstream") + + h.Resize(60, 15). + Expect("gitstream"). + Snapshot("tabs_resized") } // TestHarness_Resize verifies the layout adapts to terminal size changes. diff --git a/internal/ui/testdata/__snapshots__/tabs_resized.snap b/internal/ui/testdata/__snapshots__/tabs_resized.snap new file mode 100644 index 0000000..20b9021 --- /dev/null +++ b/internal/ui/testdata/__snapshots__/tabs_resized.snap @@ -0,0 +1,15 @@ + ◉ All │ ↑ Pushes │ ⎇ PRs +━━━━━━━┴──────────┴─────── + gitstream + Watching: owner/repo-a, owner/repo-b + ○ repo-a ○ repo-b + +Time Repo Type Actor Detail + + + + + + + + test test \ No newline at end of file From 4ad21c9cd326525c25c452b4a8b812f514c2ece6 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 19:28:16 +0200 Subject: [PATCH 13/14] feat: use SlotOverlay API and add toast feedback on keybinds - Switch all 5 overlays from WithOverlay to WithSlotOverlay (named slots) - Add toast notifications on pause, refresh, sort, filter, and clear actions - Use app.Send(ToastMsg{}) pattern from void Handler closures - Reduce tick interval from 1s to 200ms for smoother updates --- cmd/gitstream/main.go | 68 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index 4a3efe5..a0276f0 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -334,7 +334,8 @@ func main() { }, }) - app := blit.NewApp( + var app *blit.App + app = blit.NewApp( blit.WithTheme(resolveTheme(cfg.Theme)), blit.WithLayout(&blit.DualPane{ Main: tabs, @@ -348,38 +349,79 @@ func main() { }), blit.WithStatusBarSignal(leftSig, rightSig), blit.WithHelp(), - blit.WithOverlay("Settings", "c", configEditor), - blit.WithOverlay("Debug", "D", debugOverlay), - blit.WithOverlay("Detail", "", detailOverlay), - blit.WithOverlay("Theme", "ctrl+t", themePicker), - blit.WithOverlay("Command", ":", cmdBar), + blit.WithSlotOverlay("Settings", "c", configEditor), + blit.WithSlotOverlay("Debug", "D", debugOverlay), + blit.WithSlotOverlay("Detail", "", detailOverlay), + blit.WithSlotOverlay("Theme", "ctrl+t", themePicker), + blit.WithSlotOverlay("Command", ":", cmdBar), // Global keybindings blit.WithKeyBind(blit.KeyBind{ Key: "p", Label: "Pause/resume", Group: "CONTROLS", - Handler: func() { stream.TogglePause(); updateStatusRight() }, + Handler: func() { + stream.TogglePause() + updateStatusRight() + if stream.IsPaused() { + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Paused", Duration: 3 * time.Second}) + } else { + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Resumed", Duration: 3 * time.Second}) + } + }, }), blit.WithKeyBind(blit.KeyBind{ Key: "r", Label: "Refresh now", Group: "CONTROLS", - Handler: func() { stream.ForceRefresh() }, + Handler: func() { + stream.ForceRefresh() + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Refreshing…", Duration: 3 * time.Second}) + }, }), blit.WithKeyBind(blit.KeyBind{ Key: "s", Label: "Toggle sort", Group: "CONTROLS", - Handler: func() { stream.ToggleSort(); updateStatusRight() }, + Handler: func() { + stream.ToggleSort() + updateStatusRight() + if stream.IsNewestFirst() { + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Newest first", Duration: 3 * time.Second}) + } else { + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Oldest first", Duration: 3 * time.Second}) + } + }, }), blit.WithKeyBind(blit.KeyBind{ Key: "t", Label: "Type filter →", Group: "FILTER", - Handler: func() { stream.CycleTypeFilter(true); updateStatusRight() }, + Handler: func() { + stream.CycleTypeFilter(true) + updateStatusRight() + if f := stream.TypeFilter(); f != "" { + ev := github.Event{Type: f} + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Filter: " + ev.Label(), Duration: 3 * time.Second}) + } else { + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Filter: All", Duration: 3 * time.Second}) + } + }, }), blit.WithKeyBind(blit.KeyBind{ Key: "T", Label: "Type filter ←", Group: "FILTER", - Handler: func() { stream.CycleTypeFilter(false); updateStatusRight() }, + Handler: func() { + stream.CycleTypeFilter(false) + updateStatusRight() + if f := stream.TypeFilter(); f != "" { + ev := github.Event{Type: f} + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Filter: " + ev.Label(), Duration: 3 * time.Second}) + } else { + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Filter: All", Duration: 3 * time.Second}) + } + }, }), blit.WithKeyBind(blit.KeyBind{ Key: "0", Label: "Clear filters", Group: "FILTER", - Handler: func() { stream.ClearFilters(); updateStatusRight() }, + Handler: func() { + stream.ClearFilters() + updateStatusRight() + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Filters cleared", Duration: 3 * time.Second}) + }, }), blit.WithMouseSupport(), - blit.WithTickInterval(time.Second), + blit.WithTickInterval(200*time.Millisecond), blit.WithAutoUpdate(updatewire.New(version)), blit.WithDevConsole(), blit.WithAnimations(true), From f5ca934be23c8fc8bec3626085b071bb2a5c3d78 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Fri, 10 Apr 2026 20:02:12 +0200 Subject: [PATCH 14/14] fix: bump blit to v0.1.2 and remove local replace directive --- go.mod | 6 ++---- go.sum | 2 ++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index c840853..d7d336b 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,10 @@ module github.com/moneycaringcoder/gitstream-tui go 1.26.1 require ( - github.com/blitui/blit v0.1.1 + github.com/blitui/blit v0.1.2 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/x/ansi v0.11.6 ) require ( @@ -21,7 +22,6 @@ require ( 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 @@ -63,5 +63,3 @@ require ( golang.org/x/text v0.36.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/blitui/blit => ../blit diff --git a/go.sum b/go.sum index 2ce28b3..065e68b 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v 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.2 h1:2TZ836XZ9i8EOA9S8jrTtXvA9O7PqHwCih65fxA5LRY= +github.com/blitui/blit v0.1.2/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=