# TUI & Webhooks — Comprehensive Implementation Plan ## Overview Two deferred features from Phase 6: - **Interactive TUI** — bubbletea-based terminal UI for navigating repositories in a watch session, expanding details, and triggering AI suggestions on demand - **Webhook alerts** — Slack, Discord, and generic HTTP webhooks that fire when watch detects changes, complementing the existing desktop notifications ## Current state (baseline) The codebase is well-structured for these additions: - `pkg/status` — domain types (`RepoInfo`, `ScanResult`, `Summary`, `Changed`) - `internal/app` — `App.ScanOnce(ctx) (ScanResult, error)` is the entry point both TUI and webhooks consume - `internal/scheduler` — generic interval loop; run callback receives `ctx` - `internal/presenter` — `Formatter` interface (`Format(w, result) error`), color/theme plumbing via `Options`, `Suggestions()` and `Flags()` renderers - `internal/ai` — `Provider.Suggest(ctx, result) ([]Suggestion, error)` is the interface TUI can call on demand - `internal/notify` — `Send(title, body)` for desktop notifications; webhooks complement this at a higher fidelity - `internal/config` — viper-based section model; new `webhooks:` and `tui:` sections drop in naturally - `cmd/gitflow/watch.go` — the watch run closure is where both notifications and webhooks would fire; a TUI watch command would be a new cobra command Test coverage is 77–91% across domain packages (except `cmd/gitflow` at 0%, which is intentionally thin). The existing suite gives us a safety net. --- ## Part 1: Interactive TUI (`internal/tui`) ### 1.1 Architecture Bubbletea follows the Elm Architecture: **Model**, **Init**, **Update**, **View**. ``` cmd/gitflow/tui.go # new cobra command: gitflow tui internal/tui/ ├── tui.go # Program constructor, main model ├── model.go # Model struct: repos list, cursor, detail panel, ... ├── update.go # Update(msg) (Model, Cmd) — keybindings, messages ├── view.go # View(model) string — lipgloss rendering ├── views/ │ ├── repo_list.go # main repository table view │ ├── repo_detail.go # expanded single-repo detail pane │ ├── ai_panel.go # AI suggestion panel (appears on demand) │ ├── status_bar.go # bottom bar: scan time, countdown, help hint │ └── help.go # ? overlay with keybindings ├── styles.go # lipgloss.Style palette (theme-aware, dark/light) └── tui_test.go # model logic tests ``` ### 1.2 Model design ```go type Model struct { // Core data result status.ScanResult prevResult status.ScanResult aiSugs []ai.Suggestion // Navigation cursor int // selected repo index in the list detail bool // detail pane visible? aiPanel bool // AI suggestion panel visible? help bool // help overlay visible? // State loading bool // scan in progress aiLoading bool // AI request in flight error string // last error to show, cleared on next update first bool // Components tableHeight int // rows visible in repo list (terminal-size aware) width, height int // terminal dimensions // Dependencies (injected by tui.New) app *app.App ai ai.Provider opts presenter.Options cfg *config.Config } ``` ### 1.3 Messages (Bubbletea `Msg` types) ```go type scanTickMsg struct{} // interval timer fired type scanResultMsg struct{ result status.ScanResult } // scan completed type aiResultMsg struct{ suggestions []ai.Suggestion } // AI response type aiErrorMsg struct{ err error } type scanErrorMsg struct{ err error } type terminalResizeMsg struct{ width, height int } ``` ### 1.4 Keybindings | Key | Action | |---|---| | `j` / `↓` | Move cursor down | | `k` / `↑` | Move cursor up | | `g` / `Home` | Jump to top | | `G` / `End` | Jump to bottom | | `Enter` / `Space` | Toggle detail pane for selected repo | | `a` | Request AI suggestions (if provider configured) | | `r` | Trigger immediate rescan (when not in interval mode) | | `?` | Toggle help overlay | | `q` / `Ctrl-C` | Quit | ### 1.5 View structure (lipgloss) ``` ┌─ Repo list (top 75%) ───────────────────────────────────────┐ │ REPOSITORY BRANCH STATUS A/B CHANGES FLAGS│ │ ~/projects/api main ✓ clean 0/0 - │ │ > ~/projects/web feat/login ✗ mod.. 3/0 2M 1U dirty │ ← cursor │ ~/projects/lib main ⚠ behind 0/5 - stale│ │ ... │ ├─ Detail pane (on Enter, bottom 25%) ────────────────────────┤ │ ~/projects/web [dirty] │ │ branch: feat/login | remote: origin | 3 ahead 0 behind │ │ staged: 0 modified: 2 untracked: 1 stash: 0 │ │ modified: internal/handler.go, cmd/server/main.go │ │ untracked: config/local.yaml │ ├─ AI panel (on 'a', overlays bottom) ────────────────────────┤ │ AI SUGGESTIONS │ │ [high] commit — "commit the config change and the handler │ │ update" $ git add -A && git commit -m wip │ ├─ Status bar ────────────────────────────────────────────────┤ │ 12 repos | 5 clean | 6 need attention | 1 error next: 23s │ │ ? help q quit ↑↓ nav Enter detail a AI r refresh │ └──────────────────────────────────────────────────────────────┘ ``` ### 1.6 Styling Use `lipgloss` Styles — map the existing `presenter.renderer` colors to lipgloss foreground/background styles. Both dark and light themes map to the same palette the presenter uses: ```go func NewStyles(opts presenter.Options) Styles { // Dark: green=lipgloss.Color("2"), yellow="3", red="1", cyan="6", blue="4" // Light: green=lipgloss.Color("10"), yellow="11", red="9", cyan="14", blue="12" } ``` Status symbols (✓ ✗ ↑ ↓ ⇄ ◉ ▢ !) come from `presenter.statusSymbol`. ### 1.7 CLI command ``` gitflow tui [flags] Start interactive TUI with periodic scanning ``` Shares flags with `scan`/`watch`: `--dir`, `--interval`, `--exclude`, `--max-depth`, `--workers`, `--ai`, `--ai-provider`, `--ai-model`, `--theme`, `--color` (ignored — TUI is always color via lipgloss; still respects NO_COLOR by reverting to a monochrome stylesheet). ### 1.8 Program flow ```go func NewTUI(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options) *tea.Program { m := Model{ app: a, ai: aiProvider, opts: opts, cfg: cfg, first: true, } p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) // Launch a goroutine for the scan ticker that sends scanTickMsg go scanLoop(p, cfg.Interval) return p } ``` The `scanLoop` goroutine respects cancellation via a `context.Context` from `signal.NotifyContext`. Scans run via `a.ScanOnce(ctx)` and send `scanResultMsg`. On scan error it sends `scanErrorMsg` (the model displays it, but the loop continues — the user sees the last successful result). ### 1.9 Testing Bubbletea has a `test` subpackage (`github.com/charmbracelet/bubbletea-test`) or the `Send(msg)` method on Program for programmatic testing. Key test scenarios: - `TestModelNavigation` — cursor moves, wrap-around at bounds - `TestModelDetailToggle` — Enter toggles detail pane - `TestModelAISuggestions` — 'a' triggers AI and renders results - `TestModelResize` — lipgloss reflow on terminal resize - `TestModelQuit` — 'q' exits, Ctrl-C exits - `TestScanTickUpdatesModel` — received scanResultMsg updates repo list - `TestErrorDisplay` — scanErrorMsg sets error text, cleared on next scan - `TestModelStateTransitions` — loading flag prevents concurrent scans `Model` should be testable without a real terminal: views return `string`, update returns `(Model, Cmd)`, and the `test` package lets us assert the rendered output. ### 1.10 Dependencies | Dependency | Version | Purpose | |---|---|---| | `github.com/charmbracelet/bubbletea` | v1.3+ | TUI framework (Elm architecture) | | `github.com/charmbracelet/lipgloss` | v0.15+ | Declarative terminal styling | | `github.com/charmbracelet/bubbletea-test` | v0.1+ | Programmatic TUI tests (dev dep) | Both are well-maintained, widely used in the Go ecosystem, and already power tools like `lazygit`, `glow`, and `gum`. --- ## Part 2: Webhooks (`internal/webhook`) ### 2.1 Architecture ```go // Sender is the interface every webhook provider implements. type Sender interface { // Name identifies the provider (e.g. "slack", "discord"). Name() string // Send posts a payload describing the scan and which repositories changed. // It must be safe to call from multiple goroutines and should return an // error only when the upstream rejects the payload in a non-retryable way. Send(ctx context.Context, payload Payload) error } // Payload is the structured data sent to every webhook. type Payload struct { Timestamp time.Time `json:"timestamp"` ParentDir string `json:"parent_dir"` Summary status.Summary `json:"summary"` Changed []status.RepoInfo `json:"changed"` All []status.RepoInfo `json:"all,omitempty"` // when send_all: true } ``` ### 2.2 Providers #### Slack incoming webhook ```go type SlackSender struct { url string // https://hooks.slack.com/services/... client *client // shared HTTP client from internal/ai } ``` Uses Slack Block Kit (`mrkdwn` sections + context blocks) to render a compact message: summary line + changed repos with status emoji markers + a footer with the scan time. The webhook URL is configured per-target; the payload is a Slack `Message` struct marshalled to JSON. #### Discord webhook ```go type DiscordSender struct { url string // https://discord.com/api/webhooks/... client *client } ``` Discord webhooks accept a simple JSON payload (`content`, `embeds`). Renders a single rich embed with a markdown table of changed repos, color-coded by whether the scan found attention-worthy things (green for clean, yellow for some attention, red for errors). #### Generic HTTP webhook ```go type GenericSender struct { url string headers map[string]string // custom auth headers client *client } ``` Posts the `Payload` as JSON to any URL. Useful for piping into custom dashboards, CI pipelines, or webhook relay services (Zapier, n8n, Pipedream). ### 2.3 Configuration New `webhooks:` section in `~/.gitflow.yaml` (no flags — config/env only, like `rules`): ```yaml webhooks: - name: team-slack type: slack url: https://hooks.slack.com/services/... send_all: false # when true, include every repo, not just changed on_change_only: true # only fire when something changed min_priority: 0 # 0=any, 1=medium+, 2=high only rate_limit: 5m # minimum interval between sends per webhook timeout: 10s # HTTP timeout per send retry: max_attempts: 2 backoff: 1s - name: ops-discord type: discord url: https://discord.com/api/webhooks/... send_all: false on_change_only: true - name: custom-dashboard type: generic url: https://dashboard.internal/api/gitflow headers: Authorization: Bearer ${DASHBOARD_TOKEN} X-Source: gitflow ``` Config struct: ```go type WebhookConfig struct { Name string `yaml:"name"` Type string `yaml:"type"` // slack | discord | generic URL string `yaml:"url"` SendAll bool `yaml:"send_all"` OnChangeOnly bool `yaml:"on_change_only"` MinPriority int `yaml:"min_priority"` RateLimit time.Duration `yaml:"rate_limit"` Timeout time.Duration `yaml:"timeout"` Headers map[string]string `yaml:"headers,omitempty"` Retry RetryConfig `yaml:"retry"` } type RetryConfig struct { MaxAttempts int `yaml:"max_attempts"` Backoff time.Duration `yaml:"backoff"` } ``` Validation: `type` must be `slack`/`discord`/`generic`, `url` must be non-empty and start with `http://` or `https://`, `min_priority` clamped 0–2, `rate_limit` ≥ 1s, `retry.max_attempts` ≤ 5. ### 2.4 Dispatcher ```go type Dispatcher struct { senders []senderHandle } type senderHandle struct { sender Sender cfg WebhookConfig lastSent time.Time mu sync.Mutex } // Dispatch sends the payload to every sender whose filters match. func (d *Dispatcher) Dispatch(ctx context.Context, payload Payload, changedCount int) []error ``` Each sender is guarded by: - `on_change_only`: skip if `changedCount == 0` - `min_priority`: skip if no changed repo's rule-flag priority meets the threshold - `rate_limit`: skip if `lastSent + rate_limit > now` - Retry with exponential backoff on transient errors (5xx, DNS, timeout); non-retryable errors (4xx) are logged and dropped immediately ### 2.5 Integration points Webhooks fire from the same watch run closure that already handles desktop notifications: ```go // In cmd/gitflow/watch.go, inside the run closure: if !first && cfg.Notify { notifyChanges(prev, result) } if len(cfg.Webhooks) > 0 { go dispatchWebhooks(ctx, cfg.Webhooks, prev, result) } ``` They run in a background goroutine so a slow webhook never blocks the scan interval. The dispatcher manages its own context with a deadline from the webhook config timeout. ### 2.6 Testing - **Provider tests** (`internal/webhook/slack_test.go`, etc.): `httptest` server captures the JSON payload, asserts the Block Kit structure, tests the `content`/`embeds` shape. Verifies that `Payload → Slack/Discord/Geneic JSON` conversion is correct and that HTTP errors surface properly. - **Dispatcher tests**: multiple senders, rate-limit checks, on_change_only filtering, retry behavior, concurrent dispatch (no races). - **Config tests**: valid webhook configs parse; invalid ones (`type: nope`, `url: ""`, `rate_limit: 0s`) are rejected. - **Integration smoke**: a local `httptest` server stands in for Slack; a watch loop fires a scan; the webhook payload arrives. ### 2.7 Dependencies No new external dependencies — the shared HTTP `client` from `internal/ai` is reused (`internal/ai/client.go` currently is unexported; it would be promoted to `internal/httputil` or a new shared `internal/httpclient` package so both `ai` and `webhook` import it without a cycle). --- ## Part 3: Shared concerns ### 3.1 HTTP client refactor Both `internal/ai` and `internal/webhook` need an HTTP client. Currently `internal/ai/client.go` exports a package-private `client` type. The refactor: ``` internal/httpclient/ ├── client.go # exported Client with PostJSON(ctx, url, headers, body, out) error └── client_test.go ``` `internal/ai` imports `internal/httpclient` (replaces its current private `client`). `internal/webhook` imports the same package. This is a zero-behavior change; the existing AI provider tests continue to pass unmodified. ### 3.2 AI provider exposure to TUI The TUI model needs an `ai.Provider` to call `Suggest` on demand. Currently `renderSuggestions` in `cmd/gitflow/scan.go` creates the provider via `ai.NewProvider(cfg.AI)` and calls it once. For the TUI, the provider is created up-front in `tui.New()` so the model can call it on every 'a' keypress: ```go var provider ai.Provider if cfg.AI.Enabled { provider, _ = ai.NewProvider(cfg.AI) // error handled at startup } ``` A nil provider means the AI panel is unavailable (keybinding 'a' shows a "provider not configured" hint). ### 3.3 Terminal resize and signal handling Bubbletea handles `SIGWINCH` natively — `tea.WithAltScreen()` and `tea.WithMouseCellMotion()` are passed to `tea.NewProgram`. The terminal size is exposed in `tea.WindowSizeMsg`. The model updates `width`/`height` and the view reflows. For `SIGINT`/`SIGTERM`, bubbletea intercepts these and sends `tea.KeyMsg` with `ctrl+c`. The update handler maps this to `tea.Quit`. Cleanup (stopping the scan goroutine) happens in the deferred function inside `tui.New()`. ### 3.4 Config additions New `config.go` fields: ```go type Config struct { // ... existing fields ... Webhooks []config.WebhookConfig `yaml:"webhooks,omitempty"` } ``` `RegisterFlags` is unchanged (webhooks are config-file-only, like rules). A new `validateWebhooks` check runs in `Config.Validate()`. The `Dump()` method includes the webhooks section. --- ## Part 4: Milestone schedule | Milestone | Scope | Effort | |---|---|---| | **M1 — HTTP client refactor** | Extract `internal/httpclient` from `internal/ai/client.go`; update AI providers | 0.5 day | | **M2 — TUI skeleton** | `internal/tui` package, `Model` struct, bubbletea program bootstrap, `gitflow tui` cobra command, lipgloss styles, repo list view | 1 day | | **M3 — TUI interaction** | Cursor navigation, detail pane toggle, help overlay, status bar, scan loop goroutine with ticker | 1 day | | **M4 — TUI AI + polish** | AI suggestion panel on 'a', resize handling, loading states, error display, monochrome/no-color stylesheet | 0.5 day | | **M5 — Webhook core** | `internal/webhook` package, `Sender` interface, Slack + Discord + Generic implementations, dispatcher with rate-limit and retry | 1.5 days | | **M6 — Webhook config + wiring** | Config section, validation, watch-closure integration, httptest-based provider tests, dispatcher tests | 1 day | | **M7 — Integration & docs** | End-to-end TUI smoke tests, webhook round-trip against httptest, README update, `tui.md` status | 0.5 day | **Total**: ~5–6 days --- ## Part 5: Risks and trade-offs 1. **Bubbletea adds ~15 transitive deps** (lipgloss + x/term + x/ansi + a few more) — but they're all from charmbracelet or golang.org/x, well-maintained, and carry no build-time surprises. The binary grows ~800 KB, which is acceptable for a TUI tool. 2. **TUI and terminal multiplexers (tmux)** — bubbletea works well in tmux but the `alt screen` buffer requires `tmux set-option -g default-terminal "screen-256color"`. Minor documentation note. 3. **Webhook secrets in config** — webhook URLs often carry secrets (e.g. `https://hooks.slack.com/services/T00/B00/TOKEN`). The config file should be `chmod 600`. A future hardening could support reading URLs from environment variables (`url: ${SLACK_WEBHOOK_URL}`) — `os.ExpandEnv` already handles this if we run the URL through it during sender construction. 4. **TUI on Windows** — bubbletea supports Windows Terminal and ConPTY. Verifying cross-compilation and basic rendering on Windows should be part of M7 (or documented as "best-effort"). 5. **Webhook TLS** — the `http.Client` reuses the AI client's defaults (system cert pool, no custom CA). If someone needs a custom CA for an internal webhook endpoint, that's a future `webhook.tls_ca_file` setting. Out of scope for v1. 6. **TUI "compact" persona** — it's tempting to make the TUI respond to `--format`; that flag is for the CLI renderers. The TUI always uses its own layout. A `--tui-simple` flag for minimal rendering (ASCII borders, no lipgloss) is a possible later addition for constrained environments. --- ## Part 6: Acceptance criteria (per milestone) ### M1 — HTTP client refactor - `internal/httpclient` package with `Client.PostJSON` method - `internal/ai` providers switched to it; all ai tests pass unchanged - `go test -race ./...` green ### M2 — TUI skeleton - `gitflow tui` command launches, renders a static repo list, quits on 'q' - lipgloss styles match the CLI's dark/light theme palette - `tea.NewProgram` wired with `WithAltScreen()` ### M3 — TUI interaction - Arrow keys and j/k navigate the list; Enter toggles detail pane - Status bar shows scan time, countdown, and keybinding hints - `?` shows help overlay; resize reflows the layout - Periodic scan loop sends `scanResultMsg` and the list updates ### M4 — TUI AI + polish - 'a' triggers `ai.Suggest` and renders the AI panel; loading/error states - Ctrl-C quits gracefully (no goroutine leaks) - Model logic tests cover navigation, detail, AI panel, error, quit ### M5 — Webhook core - `SlackSender`, `DiscordSender`, `GenericSender` implement `Sender` - `Dispatcher.Dispatch` respects `on_change_only`, `min_priority`, `rate_limit` - Retry with backoff on 5xx; 4xx returns error immediately - httptest-based tests for each sender's payload shape ### M6 — Webhook config + wiring - `webhooks:` section parseable from `~/.gitflow.yaml` with full validation - Watch closure fires `Dispatch` in a goroutine after each scan - Config dump includes webhooks section - Dispatcher tests cover multiple senders, rate-limit, filtering ### M7 — Integration & docs - Full TUI smoke test: launch, navigate, expand detail, trigger AI, quit - Webhook smoke: httptest server receives payload through the dispatcher - README updated with `gitflow tui` and webhooks sections - Cross-compile verification (linux/amd64, darwin/arm64, windows/amd64)