23 KiB
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 consumeinternal/scheduler— generic interval loop; run callback receivesctxinternal/presenter—Formatterinterface (Format(w, result) error), color/theme plumbing viaOptions,Suggestions()andFlags()renderersinternal/ai—Provider.Suggest(ctx, result) ([]Suggestion, error)is the interface TUI can call on demandinternal/notify—Send(title, body)for desktop notifications; webhooks complement this at a higher fidelityinternal/config— viper-based section model; newwebhooks:andtui:sections drop in naturallycmd/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
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)
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:
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
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 boundsTestModelDetailToggle— Enter toggles detail paneTestModelAISuggestions— 'a' triggers AI and renders resultsTestModelResize— lipgloss reflow on terminal resizeTestModelQuit— 'q' exits, Ctrl-C exitsTestScanTickUpdatesModel— received scanResultMsg updates repo listTestErrorDisplay— scanErrorMsg sets error text, cleared on next scanTestModelStateTransitions— 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
// 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
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
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
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):
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:
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
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 ifchangedCount == 0min_priority: skip if no changed repo's rule-flag priority meets the thresholdrate_limit: skip iflastSent + 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:
// 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.):httptestserver captures the JSON payload, asserts the Block Kit structure, tests thecontent/embedsshape. Verifies thatPayload → Slack/Discord/Geneic JSONconversion 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
httptestserver 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:
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:
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
-
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.
-
TUI and terminal multiplexers (tmux) — bubbletea works well in tmux but the
alt screenbuffer requirestmux set-option -g default-terminal "screen-256color". Minor documentation note. -
Webhook secrets in config — webhook URLs often carry secrets (e.g.
https://hooks.slack.com/services/T00/B00/TOKEN). The config file should bechmod 600. A future hardening could support reading URLs from environment variables (url: ${SLACK_WEBHOOK_URL}) —os.ExpandEnvalready handles this if we run the URL through it during sender construction. -
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").
-
Webhook TLS — the
http.Clientreuses 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 futurewebhook.tls_ca_filesetting. Out of scope for v1. -
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-simpleflag 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/httpclientpackage withClient.PostJSONmethodinternal/aiproviders switched to it; all ai tests pass unchangedgo test -race ./...green
M2 — TUI skeleton
gitflow tuicommand launches, renders a static repo list, quits on 'q'- lipgloss styles match the CLI's dark/light theme palette
tea.NewProgramwired withWithAltScreen()
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
scanResultMsgand the list updates
M4 — TUI AI + polish
- 'a' triggers
ai.Suggestand 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,GenericSenderimplementSenderDispatcher.Dispatchrespectson_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.yamlwith full validation- Watch closure fires
Dispatchin 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 tuiand webhooks sections - Cross-compile verification (linux/amd64, darwin/arm64, windows/amd64)
Implementation Progress
All milestones from this plan are implemented and merged into main.
| Milestone | Branch | Status |
|---|---|---|
| M1 — HTTP client refactor | feat/tui-webhooks-m1-httpclient |
✅ done |
| M2 — TUI skeleton | feat/tui-webhooks-m2-tui-skeleton |
✅ done |
| M3 — TUI interaction | feat/tui-webhooks-m3-tui-interaction |
✅ done |
| M4 — TUI AI + polish | feat/tui-webhooks-m4-tui-ai-polish |
✅ done |
| M5 — Webhook core | feat/tui-webhooks-m5-webhook-core |
✅ done |
| M6 — Webhook config + wiring | feat/tui-webhooks-m6-webhook-config |
✅ done |
| M7 — Integration & docs | (squashed into M6 merge) | ✅ done |
Verification
- go build, go vet, go test -race (13 packages), gofmt clean — all green
- Cross-compilation: windows/amd64, darwin/arm64
gitflow tui --helpprints usage; model logic tests cover navigation, AI, and views- Webhook senders round-trip through httptest; Slack Block Kit and Discord embed shapes verified
- Webhook config parsed from YAML with duration fixup; bad configs rejected
- Watch closure fires dispatcher in a goroutine per frame