gitFlow/internal/tui/tui.go
dimitar a44c2e46c7 feat: M4 — TUI AI panel on 'a' with toggle, loading/error states
Add the on-demand AI suggestion panel to the TUI so pressing 'a' asks the
configured provider about the current scan result.

Model additions:
- aiSugs field ([]ai.Suggestion) and aiCmd() method that calls
  ai.Provider.Suggest(context.Background(), result)
- aiResultMsg/aiErrorMsg message types for the async flow

Update handler:
- 'a' toggles the panel: if visible, it hides; if hidden, it starts the AI
  request (aiLoading flag guards against duplicates)
- aiResultMsg populates aiSugs and opens the panel
- aiErrorMsg prepends "AI:" to the error and places it in the status bar
  error field (non-blocking — the scan result stays visible)

View:
- aiPanelView renders a separator line, "AI SUGGESTIONS" header, and one
  row per suggestion: [priority] action message, with an indented "$ cmd"
  line when a command is present
- loading state shows " asking AI..." with the dim style
- status bar right-hand hints include "a AI" only when a provider is
  configured (nil check)

Tests:
- 'a' fires aiCmd; second 'a' toggles the panel off
- aiResultMsg opens the panel and populates aiSugs
- aiErrorMsg closes the panel and sets the error with "AI:" prefix
- aiPanelView contains "AI SUGGESTIONS", priority labels, and messages
- AI toggle hides the panel on second press

Verified: go build, go vet, go test -race (12 packages), gofmt clean.
2026-08-02 09:12:56 +02:00

449 lines
11 KiB
Go

// Package tui provides an interactive terminal UI for browsing gitflow scan
// results using bubbletea.
package tui
import (
"context"
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbletea"
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// Model is the top-level bubbletea model for the TUI.
type Model struct {
app *app.App
ai ai.Provider // nil when AI is disabled
cfg *config.Config
opts presenter.Options
result status.ScanResult
prevResult status.ScanResult
cursor int
detail bool
help bool
aiPanel bool
aiSugs []ai.Suggestion
width int
height int
loading bool
aiLoading bool
err string
first bool
interval time.Duration
lastScan time.Time
changed map[string]bool // repo paths whose state changed since previous scan
styles Styles
}
// New builds a bubbletea Program for the TUI. It runs an initial scan
// synchronously so the first frame shows results immediately.
func New(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options) (*tea.Program, error) {
result, err := a.ScanOnce(context.Background())
if err != nil {
return nil, fmt.Errorf("tui: initial scan: %w", err)
}
return newProgram(cfg, a, aiProvider, opts, result, false), nil
}
func newProgram(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options, result status.ScanResult, loading bool) *tea.Program {
m := Model{
app: a,
ai: aiProvider,
cfg: cfg,
opts: opts,
result: result,
cursor: 0,
detail: false,
first: true,
interval: cfg.Interval,
lastScan: time.Now(),
changed: map[string]bool{},
styles: NewStyles(opts),
}
if loading {
m.loading = true
}
return tea.NewProgram(m, tea.WithAltScreen())
}
// Init is the initial command.
func (m Model) Init() tea.Cmd {
if m.loading {
return m.scanCmd
}
if m.interval > 0 {
return tea.Tick(m.interval, func(t time.Time) tea.Msg {
return scanTickMsg{t}
})
}
return nil
}
// scanTickMsg is sent by the ticker every interval.
type scanTickMsg struct{ at time.Time }
// scanResultMsg carries a completed scan.
type scanResultMsg struct{ result status.ScanResult }
// scanErrorMsg carries a failed scan.
type scanErrorMsg struct{ err error }
// aiResultMsg carries AI suggestions.
type aiResultMsg struct{ suggestions []ai.Suggestion }
// aiErrorMsg carries a failed AI request.
type aiErrorMsg struct{ err error }
// scanCmd triggers a scan in the background.
func (m Model) scanCmd() tea.Msg {
result, err := m.app.ScanOnce(context.Background())
if err != nil {
return scanErrorMsg{err}
}
return scanResultMsg{result}
}
// aiCmd triggers an AI suggestion request.
func (m Model) aiCmd() tea.Msg {
if m.ai == nil {
return aiErrorMsg{fmt.Errorf("no AI provider configured (use --ai)")}
}
sugs, err := m.ai.Suggest(context.Background(), m.result)
if err != nil {
return aiErrorMsg{err}
}
return aiResultMsg{sugs}
}
// ---- update ----------------------------------------------------------------
// Update handles incoming messages.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "?":
m.help = !m.help
case "down", "j":
if m.cursor < len(m.result.Repos)-1 {
m.cursor++
}
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "home", "g":
m.cursor = 0
case "end", "G":
if n := len(m.result.Repos); n > 0 {
m.cursor = n - 1
}
case "enter", " ":
m.detail = !m.detail
case "r":
if !m.loading {
m.loading = true
return m, m.scanCmd
}
case "a":
if m.aiPanel {
m.aiPanel = false
} else if !m.aiLoading {
m.aiLoading = true
return m, m.aiCmd
}
}
case scanTickMsg:
if !m.loading {
m.loading = true
return m, m.scanCmd
}
if m.interval > 0 {
return m, tea.Tick(m.interval, func(t time.Time) tea.Msg {
return scanTickMsg{t}
})
}
case scanResultMsg:
if !m.first {
m.prevResult = m.result
m.changed = make(map[string]bool, len(msg.result.Repos))
for _, r := range status.Changed(m.prevResult, msg.result) {
m.changed[r.Path] = true
}
}
m.result = msg.result
m.loading = false
m.err = ""
m.lastScan = time.Now()
if m.first {
m.first = false
}
if m.interval > 0 {
return m, tea.Tick(m.interval, func(t time.Time) tea.Msg {
return scanTickMsg{t}
})
}
case scanErrorMsg:
m.err = msg.err.Error()
m.loading = false
m.lastScan = time.Now()
if m.interval > 0 {
return m, tea.Tick(m.interval, func(t time.Time) tea.Msg {
return scanTickMsg{t}
})
}
case aiResultMsg:
m.aiSugs = msg.suggestions
m.aiLoading = false
m.aiPanel = true
case aiErrorMsg:
m.err = "AI: " + msg.err.Error()
m.aiLoading = false
m.aiPanel = false
}
return m, nil
}
// ---- view ------------------------------------------------------------------
// View renders the full TUI.
func (m Model) View() string {
if m.help {
return m.helpView()
}
var b strings.Builder
b.WriteString(m.repoListView())
b.WriteString("\n")
if m.detail && m.cursor < len(m.result.Repos) {
b.WriteString(m.detailView(m.result.Repos[m.cursor]))
b.WriteString("\n")
}
if m.aiPanel {
b.WriteString(m.aiPanelView())
b.WriteString("\n")
}
b.WriteString(m.statusBarView())
return b.String()
}
// ---- repo list -------------------------------------------------------------
func (m Model) repoListView() string {
if m.loading && m.first {
return fmt.Sprintf("\n %s scanning %s...\n", m.styles.Dim("⏳"), m.cfg.Dir)
}
if len(m.result.Repos) == 0 {
return fmt.Sprintf("\n %s\n", m.styles.Dim("no repositories found under "+m.cfg.Dir))
}
var b strings.Builder
header := m.styles.Header
fmt.Fprintf(&b, "%s%-5s %-30s %-15s %-10s %5s %6s\n%s",
header.Render(""),
header.Render("STATUS"),
header.Render("REPOSITORY"),
header.Render("BRANCH"),
header.Render("AHEAD/BEHIND"),
header.Render("CHGS"),
header.Render("STASH"),
header.Render(""),
)
for i, repo := range m.result.Repos {
changed := m.changed[repo.Path]
cursor := " "
if i == m.cursor {
cursor = ">"
} else if changed {
cursor = "▲"
}
ab := "-"
if repo.AheadBy > 0 || repo.BehindBy > 0 {
ab = fmt.Sprintf("%d/%d", repo.AheadBy, repo.BehindBy)
}
chs := "-"
if n := repo.FileCount(); n > 0 {
chs = fmt.Sprintf("%d", n)
}
stash := "-"
if repo.StashCount > 0 {
stash = fmt.Sprintf("%d", repo.StashCount)
}
line := fmt.Sprintf("%s %-5s %-30s %-15s %5s %6s %6s\n",
cursor,
presenter.StatusSymbolFor(repo.Status),
truncate(shortPath(repo.Path), 29),
truncate(repo.Branch, 14),
ab,
chs,
stash,
)
line = m.colorLine(repo.Status, cursor, line)
if changed && cursor != ">" {
line = m.styles.Changed.Render(line)
}
if i == m.cursor {
b.WriteString(m.styles.CursorRow.Render(line))
} else {
b.WriteString(m.styles.Row.Render(line))
}
}
return b.String()
}
func (m Model) colorLine(s status.RepoStatus, cursor string, line string) string {
switch s {
case status.StatusError:
return m.styles.Dim(line)
}
return line
}
// ---- detail view -----------------------------------------------------------
func (m Model) detailView(repo status.RepoInfo) string {
var b strings.Builder
w := m.width
if w < 40 {
w = 80
}
sep := m.styles.Dim(strings.Repeat("─", max(0, w-2)))
b.WriteString(sep + "\n")
line := fmt.Sprintf(" %s branch: %s | remote: %s | ahead: %d behind: %d",
repo.Path, repo.Branch, truncate(repo.RemoteURL, 30), repo.AheadBy, repo.BehindBy)
b.WriteString(m.styles.Dim(line) + "\n")
d := fmt.Sprintf(" staged: %d modified: %d untracked: %d stash: %d",
len(repo.StagedFiles), len(repo.ModifiedFiles), len(repo.UntrackedFiles), repo.StashCount)
b.WriteString(d + "\n")
if repo.Error != "" {
b.WriteString(m.styles.Err(repo.Error) + "\n")
}
if len(repo.StagedFiles) > 0 {
b.WriteString(" staged: " + strings.Join(repo.StagedFiles, ", ") + "\n")
}
if len(repo.ModifiedFiles) > 0 {
b.WriteString(" modified: " + strings.Join(repo.ModifiedFiles, ", ") + "\n")
}
if len(repo.UntrackedFiles) > 0 {
b.WriteString(" untracked: " + strings.Join(repo.UntrackedFiles, ", ") + "\n")
}
return b.String()
}
// ---- status bar ------------------------------------------------------------
func (m Model) statusBarView() string {
s := m.result.Summary()
left := fmt.Sprintf("%d repos | %d clean | %d need attention | %d errors",
s.Total, s.Clean, s.Attention, s.Errored)
if m.err != "" {
left += " [error: " + m.err + "]"
}
if m.interval > 0 {
elapsed := time.Since(m.lastScan).Round(time.Second)
next := m.interval - elapsed
if next < 0 {
next = 0
}
left += fmt.Sprintf(" | next: %v", next)
}
if m.loading {
left += " | scanning…"
}
right := "r refresh ? help q quit ↑↓ nav Enter detail"
if m.ai != nil {
right += " a AI"
}
bar := left + strings.Repeat(" ", max(0, m.width-len(left)-len(right))) + right
return m.styles.StatusBar.Render(bar)
}
// ---- AI panel -------------------------------------------------------------
func (m Model) aiPanelView() string {
if m.aiLoading {
return m.styles.Dim(" ⏳ asking AI...")
}
if len(m.aiSugs) == 0 {
return m.styles.Dim(" AI: no suggestions")
}
var b strings.Builder
w := m.width
if w < 40 {
w = 80
}
sep := m.styles.Dim(strings.Repeat("─", max(0, w-2)))
b.WriteString(sep + "\n")
b.WriteString(" AI SUGGESTIONS\n")
for _, s := range m.aiSugs {
prio := "low"
switch s.Priority {
case 2:
prio = "high"
case 1:
prio = "medium"
}
b.WriteString(fmt.Sprintf(" [%s] %-8s %s\n", prio, truncate(s.Action, 8), s.Message))
if s.Command != "" {
b.WriteString(fmt.Sprintf(" $ %s\n", s.Command))
}
}
return b.String()
}
// ---- help ------------------------------------------------------------------
func (m Model) helpView() string {
return m.styles.Dim(
"\n KEYBINDINGS\n" +
" ↑/↓ or j/k g/G navigate\n" +
" Enter / Space toggle detail pane\n" +
" r refresh now\n" +
" a AI suggestions (requires --ai)\n" +
" ? toggle this help\n" +
" q / Ctrl-C quit\n",
)
}
// ---- helpers ---------------------------------------------------------------
func shortPath(p string) string {
return presenter.ShortPath(p)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}