diff --git a/AGENTS.md b/AGENTS.md index 39358c69..c5b1e80f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,7 +133,7 @@ Use this checklist to track work. Keep it prioritized, update statuses, and refe ### Prioritized Backlog - [x] TUI: Charts expand-on-click (Charts 2/3 vs Queues 1/3; toggle back on Queues click) -- [ ] TUI: Integrate `bubblezone` for precise mouse hitboxes (tabs, table rows, future context menus) +- [x] TUI: Integrate `bubblezone` for precise mouse hitboxes (tabs, table rows, future context menus) - [ ] Real green: capacity planning/forecasting/policy simulator suite - [ ] Real green: distributed tracing integration suite - [ ] Real green: job budgeting suite @@ -858,4 +858,3 @@ Please keep this document up-to-date with records of what you've worked on as yo ### Suggested Stabilization Order advanced-rate-limiting → anomaly-radar-slo-budget → automatic-capacity-planning → breaker → calendar-view → canary-deployments → chaos-harness → collaborative-session → config → dlq-remediation-pipeline → event-hooks → exactly-once-patterns → exactly_once → forecasting → job-budgeting → job-genealogy-navigator → json-payload-studio → kubernetes-operator → long-term-archives → multi-tenant-isolation → patterned-load-generator → plugin-panel-system → policy-simulator → producer-backpressure → queue → queue-snapshot-testing → rbac-and-tokens → right-click-context-menus → smart-payload-deduplication → smart-retry-strategies → storage-backends → tenant → terminal-voice-commands → theme-playground → time-travel-debugger → visual-dag-builder → worker-fleet-controls → distributed-tracing-integration → redisclient → obs → admin → producer → reaper → worker → admin-api → multi-cluster-control → trace-drilldown-log-tail → tui - diff --git a/go.mod b/go.mod index 7dc15806..4cf8d397 100644 --- a/go.mod +++ b/go.mod @@ -62,9 +62,9 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -105,6 +105,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/lrstanley/bubblezone v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect diff --git a/go.sum b/go.sum index 051d5e9c..9ca6400f 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,8 @@ github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZ github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= @@ -57,6 +59,8 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7 github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= @@ -190,6 +194,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= +github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= +github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= diff --git a/internal/tui/app.go b/internal/tui/app.go index 18b7c1f1..13711742 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -242,28 +242,33 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseMsg: if !m.confirmOpen { - // Tab bar click handling (first row) - if msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress && msg.Y == 0 { - _, zones := m.buildTabBar() - for _, z := range zones { - if msg.X >= z.start && msg.X < z.end { - m.activeTab = z.id - return m, nil - } + if msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress { + if tab, ok := m.tabAtMouse(msg); ok { + m.activeTab = tab + return m, nil } } // Charts expand-on-click with spring animation: right half expands, left half returns to balanced if msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress && m.activeTab == tabJobs { + if idx, ok := m.rowIndexAtMouse(msg); ok { + if idx >= 0 && idx < len(m.peekTargets) { + m.tbl.SetCursor(idx) + m.focus = focusQueues + m.loading = true + m.errText = "" + cmds = append(cmds, m.doPeekCmd(m.peekTargets[idx], 10), spinner.Tick) + } + return m, tea.Batch(cmds...) + } if msg.X > m.width/2 { m.expTarget = 1.0 m.expActive = true return m, tea.Tick(16*time.Millisecond, func(time.Time) tea.Msg { return animTick{} }) - } else { - m.expTarget = 0.0 - m.expActive = true - return m, tea.Tick(16*time.Millisecond, func(time.Time) tea.Msg { return animTick{} }) } + m.expTarget = 0.0 + m.expActive = true + return m, tea.Tick(16*time.Millisecond, func(time.Time) tea.Msg { return animTick{} }) } switch msg.Button { case tea.MouseButtonWheelUp: @@ -416,3 +421,29 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } + +func (m model) tabAtMouse(msg tea.MouseMsg) (tabID, bool) { + if m.zones == nil { + return 0, false + } + order := []tabID{tabJobs, tabWorkers, tabDLQ, tabTimeTravel, tabEventHooks, tabSettings} + for _, id := range order { + if z := m.zones.Get(m.tabZoneID(id)); z != nil && z.InBounds(msg) { + return id, true + } + } + return 0, false +} + +func (m model) rowIndexAtMouse(msg tea.MouseMsg) (int, bool) { + if m.zones == nil { + return 0, false + } + start, end := m.visibleRowRange() + for idx := start; idx < end; idx++ { + if z := m.zones.Get(m.tableRowZoneID(idx)); z != nil && z.InBounds(msg) { + return idx, true + } + } + return 0, false +} diff --git a/internal/tui/init.go b/internal/tui/init.go index f55346bd..94ab733d 100644 --- a/internal/tui/init.go +++ b/internal/tui/init.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/harmonica" "github.com/charmbracelet/lipgloss" + zone "github.com/lrstanley/bubblezone" tchelp "github.com/mistakenelf/teacup/help" "github.com/mistakenelf/teacup/statusbar" "github.com/redis/go-redis/v9" @@ -22,6 +23,7 @@ import ( func initialModel(cfg *config.Config, rdb *redis.Client, logger *zap.Logger, refreshEvery time.Duration, opts Options) model { ctx, cancel := context.WithCancel(context.Background()) + zones := zone.New() sp := spinner.New() sp.Spinner = spinner.Dot @@ -81,37 +83,40 @@ func initialModel(cfg *config.Config, rdb *redis.Client, logger *zap.Logger, ref } return model{ - ctx: ctx, - cancel: cancel, - cfg: cfg, - rdb: rdb, - logger: logger, - opts: opts, - focus: focusQueues, - help: help.New(), - spinner: sp, - tbl: t, - benchCount: bi, - benchRate: br, - benchPriority: bp, - benchTimeout: bt, - refreshEvery: refreshEvery, - tableTopY: 3, - series: map[string][]float64{"high": {}, "low": {}, "completed": {}, "dead_letter": {}}, - seriesMax: 180, - filter: fi, - vpCharts: viewport.New(0, 10), - vpInfo: viewport.New(0, 10), - boxTitle: boxTitle, - boxBody: boxBody, - sb: sb, - help2: help2, - pb: bubprog.New(bubprog.WithDefaultGradient()), - activeTab: tabJobs, - spring: harmonica.NewSpring(harmonica.FPS(fps), 6.0, 0.25), - expPos: 0.0, - expVel: 0.0, - expTarget: 0.0, - expActive: false, + ctx: ctx, + cancel: cancel, + cfg: cfg, + rdb: rdb, + logger: logger, + opts: opts, + zones: zones, + tabZonePrefix: zones.NewPrefix(), + tableZonePrefix: zones.NewPrefix(), + focus: focusQueues, + help: help.New(), + spinner: sp, + tbl: t, + benchCount: bi, + benchRate: br, + benchPriority: bp, + benchTimeout: bt, + refreshEvery: refreshEvery, + tableTopY: 3, + series: map[string][]float64{"high": {}, "low": {}, "completed": {}, "dead_letter": {}}, + seriesMax: 180, + filter: fi, + vpCharts: viewport.New(0, 10), + vpInfo: viewport.New(0, 10), + boxTitle: boxTitle, + boxBody: boxBody, + sb: sb, + help2: help2, + pb: bubprog.New(bubprog.WithDefaultGradient()), + activeTab: tabJobs, + spring: harmonica.NewSpring(harmonica.FPS(fps), 6.0, 0.25), + expPos: 0.0, + expVel: 0.0, + expTarget: 0.0, + expActive: false, } } diff --git a/internal/tui/model.go b/internal/tui/model.go index c42575b4..a79fed73 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/harmonica" "github.com/charmbracelet/lipgloss" + zone "github.com/lrstanley/bubblezone" tchelp "github.com/mistakenelf/teacup/help" "github.com/mistakenelf/teacup/statusbar" "github.com/redis/go-redis/v9" @@ -133,6 +134,11 @@ type model struct { // Tabs activeTab tabID + // Bubblezone manager + prefixes for zones + zones *zone.Manager + tabZonePrefix string + tableZonePrefix string + // Expansion animation (Jobs: Queues | Charts) spring harmonica.Spring expPos float64 // 0.0 = 50/50, 1.0 = Charts expanded (1:2) diff --git a/internal/tui/tabs.go b/internal/tui/tabs.go index 3ad934b0..9cd22115 100644 --- a/internal/tui/tabs.go +++ b/internal/tui/tabs.go @@ -7,13 +7,7 @@ import ( ) // tabZone defines a clickable region for a tab on the first row -type tabZone struct { - id tabID - start int // inclusive x - end int // exclusive x -} - -func (m model) buildTabBar() (string, []tabZone) { +func (m model) buildTabBar() string { // Labels in order items := []struct { id tabID @@ -34,7 +28,6 @@ func (m model) buildTabBar() (string, []tabZone) { inactive := base.Foreground(lipgloss.AdaptiveColor{Dark: "#bbbbbb", Light: "#333333"}).BorderForeground(lipgloss.AdaptiveColor{Dark: "#555555", Light: "#cccccc"}) b := &strings.Builder{} - zones := make([]tabZone, 0, len(items)) x := 0 // left margin leftPad := " " @@ -46,9 +39,8 @@ func (m model) buildTabBar() (string, []tabZone) { if it.id == m.activeTab { st = base.Bold(true).Foreground(lipgloss.Color(it.color)).BorderForeground(lipgloss.Color(it.color)) } - seg := st.Render(it.label) + seg := m.zones.Mark(m.tabZoneID(it.id), st.Render(it.label)) b.WriteString(seg) - zones = append(zones, tabZone{id: it.id, start: x, end: x + lipgloss.Width(seg)}) x += lipgloss.Width(seg) if i != len(items)-1 { sep := " " @@ -56,5 +48,5 @@ func (m model) buildTabBar() (string, []tabZone) { x += lipgloss.Width(sep) } } - return b.String(), zones + return b.String() } diff --git a/internal/tui/view.go b/internal/tui/view.go index 2451f2cf..2974f0fb 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -16,7 +16,7 @@ import ( func (m model) View() string { // Tab bar - tabBar, _ := m.buildTabBar() + tabBar := m.buildTabBar() headerText := fmt.Sprintf("Job Queue TUI — Redis %s", m.cfg.Redis.Addr) if m.opts.Cluster != "" { @@ -137,7 +137,7 @@ func (m model) View() string { tblH = 3 } m.tbl.SetHeight(tblH) - leftBody := m.tbl.View() + leftBody := m.markTableZones(m.tbl.View()) if fb := renderFilterBar(m); fb != "" { leftBody = fb + "\n" + leftBody } @@ -256,7 +256,7 @@ func (m model) View() string { base := tabBar + "\n" + header + "\n" + sub + "\n\n" + body if m.confirmOpen { // Use a full-screen scrim overlay that centers the modal and preserves header/body - return renderOverlayScreen(m) + return m.zones.Scan(renderOverlayScreen(m)) } now := time.Now().Format("15:04:05") m.sb.SetContent("Redis "+m.cfg.Redis.Addr, "focus:"+focusName(m.focus), m.spinner.View(), now) @@ -265,7 +265,7 @@ func (m model) View() string { // Dim with scrim and center the help content out = renderHelpOverlay(m, "") } - return out + return m.zones.Scan(out) } func summarizeKeys(k admin.KeysStats) string { @@ -380,3 +380,43 @@ func renderFilterBar(m model) string { } return "Press 'f' to filter queues" } + +func (m model) tabZoneID(id tabID) string { + return fmt.Sprintf("%s-tab-%d", m.tabZonePrefix, id) +} + +func (m model) tableRowZoneID(idx int) string { + return fmt.Sprintf("%s-row-%d", m.tableZonePrefix, idx) +} + +func (m model) visibleRowRange() (int, int) { + rows := m.tbl.Rows() + if len(rows) == 0 { + return 0, 0 + } + height := m.tbl.Height() + cursor := clamp(m.tbl.Cursor(), 0, len(rows)-1) + start := clamp(cursor-height, 0, cursor) + end := clamp(cursor+height, cursor, len(rows)) + if start > end { + start = end + } + return start, end +} + +func (m model) markTableZones(tableView string) string { + lines := strings.Split(tableView, "\n") + if len(lines) <= 1 { + return tableView + } + start, end := m.visibleRowRange() + rowIdx := start + for i := 1; i < len(lines) && rowIdx < end; i++ { + if strings.TrimSpace(lines[i]) == "" { + continue + } + lines[i] = m.zones.Mark(m.tableRowZoneID(rowIdx), lines[i]) + rowIdx++ + } + return strings.Join(lines, "\n") +}