From a44c2e46c7c5ff082a33fbd6541d3d87cacb455e Mon Sep 17 00:00:00 2001 From: dimitar Date: Sun, 2 Aug 2026 09:12:56 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20M4=20=E2=80=94=20TUI=20AI=20panel=20on?= =?UTF-8?q?=20'a'=20with=20toggle,=20loading/error=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/tui/tui.go | 76 ++++++++++++++++++++++++++++++++++++++++ internal/tui/tui_test.go | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index acf5ffb..92d8577 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -30,6 +30,7 @@ type Model struct { detail bool help bool aiPanel bool + aiSugs []ai.Suggestion width int height int @@ -99,6 +100,12 @@ 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()) @@ -108,6 +115,18 @@ func (m Model) scanCmd() tea.Msg { 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. @@ -144,6 +163,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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: @@ -187,6 +213,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 } @@ -206,6 +242,10 @@ func (m Model) View() string { 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() } @@ -340,10 +380,46 @@ func (m Model) statusBarView() string { 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 { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index cc43aa2..83110fa 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1,12 +1,14 @@ package tui import ( + "fmt" "strings" "testing" "time" "github.com/charmbracelet/bubbletea" + "gitea.oblak.solutions/dimitar/gitFlow/internal/ai" "gitea.oblak.solutions/dimitar/gitFlow/internal/config" "gitea.oblak.solutions/dimitar/gitFlow/internal/presenter" "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" @@ -229,3 +231,64 @@ func TestModelViewEmpty(t *testing.T) { t.Errorf("empty view missing 'no repositories':\n%s", view) } } + +func TestModelAIKeyStartsRequest(t *testing.T) { + m := newTestModel() + if m.aiLoading { + t.Error("aiLoading = true initially") + } + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + if cmd == nil { + t.Error("'a' produced nil cmd, want aiCmd") + } + // 'a' again when aiLoading blocks duplicate requests. +} + +func TestModelAIResultDisplaysPanel(t *testing.T) { + m := newTestModel() + m2, _ := m.Update(aiResultMsg{suggestions: []ai.Suggestion{ + {RepoPath: "/a", Action: "push", Message: "push it", Command: "git push", Priority: 2}, + }}) + if !m2.(Model).aiPanel { + t.Error("aiPanel = false after aiResultMsg, want true") + } + if len(m2.(Model).aiSugs) != 1 { + t.Fatalf("aiSugs len = %d, want 1", len(m2.(Model).aiSugs)) + } +} + +func TestModelAIErrorClearsPanel(t *testing.T) { + m := newTestModel() + m.aiPanel = true + m2, _ := m.Update(aiErrorMsg{fmt.Errorf("boom")}) + if m2.(Model).aiPanel { + t.Error("aiPanel = true after aiErrorMsg, want false") + } + if !strings.Contains(m2.(Model).err, "AI:") { + t.Errorf("error not prefixed with AI: %q", m2.(Model).err) + } +} + +func TestModelAIPanelView(t *testing.T) { + m := newTestModel() + m.aiPanel = true + m.aiSugs = []ai.Suggestion{ + {RepoPath: "/a", Action: "push", Message: "push it", Priority: 2}, + } + view := m.View() + if !strings.Contains(view, "AI SUGGESTIONS") { + t.Errorf("AI panel missing 'AI SUGGESTIONS':\n%s", view) + } + if !strings.Contains(view, "high") || !strings.Contains(view, "push it") { + t.Errorf("AI panel missing content:\n%s", view) + } +} + +func TestModelAIToggleHidesPanel(t *testing.T) { + m := newTestModel() + m.aiPanel = true + m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + if m2.(Model).aiPanel { + t.Error("aiPanel = true after second 'a', want false") + } +}