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.
295 lines
8.0 KiB
Go
295 lines
8.0 KiB
Go
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"
|
|
)
|
|
|
|
func newTestModel() Model {
|
|
return newTestModelWith(status.ScanResult{
|
|
ScannedAt: time.Now(),
|
|
ParentDir: "/tmp/test",
|
|
Repos: []status.RepoInfo{
|
|
{Path: "/tmp/test/a", Name: "a", Branch: "main", Status: status.StatusClean},
|
|
{Path: "/tmp/test/b", Name: "b", Branch: "feat/x", Status: status.StatusModified, ModifiedFiles: []string{"f.go"}},
|
|
{Path: "/tmp/test/c", Name: "c", Status: status.StatusError, Error: "boom"},
|
|
},
|
|
})
|
|
}
|
|
|
|
func newTestModelWith(result status.ScanResult) Model {
|
|
return Model{
|
|
app: nil,
|
|
cfg: &config.Config{Dir: "/tmp/test", Format: "table", Color: "auto", Theme: "dark", Workers: 4},
|
|
opts: presenter.Options{Color: presenter.ColorNever, Theme: presenter.ThemeDark},
|
|
result: result,
|
|
cursor: 0,
|
|
first: true,
|
|
styles: NewStyles(presenter.Options{Theme: presenter.ThemeDark}),
|
|
}
|
|
}
|
|
|
|
func TestModelNavigation(t *testing.T) {
|
|
m := newTestModel()
|
|
|
|
// Move down
|
|
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
if m2.(Model).cursor != 1 {
|
|
t.Errorf("cursor = %d after down, want 1", m2.(Model).cursor)
|
|
}
|
|
// Move down again
|
|
m3, _ := m2.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
if m3.(Model).cursor != 2 {
|
|
t.Errorf("cursor = %d after down, want 2", m3.(Model).cursor)
|
|
}
|
|
// At bottom: cursor stays
|
|
m4, _ := m3.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
if m4.(Model).cursor != 2 {
|
|
t.Errorf("cursor = %d after down (at bottom), want 2", m4.(Model).cursor)
|
|
}
|
|
// Move up
|
|
m5, _ := m4.Update(tea.KeyMsg{Type: tea.KeyUp})
|
|
if m5.(Model).cursor != 1 {
|
|
t.Errorf("cursor = %d after up, want 1", m5.(Model).cursor)
|
|
}
|
|
// At top: cursor stays
|
|
m6, _ := m5.Update(tea.KeyMsg{Type: tea.KeyUp})
|
|
m6, _ = m6.Update(tea.KeyMsg{Type: tea.KeyUp})
|
|
if m6.(Model).cursor != 0 {
|
|
t.Errorf("cursor = %d after up (at top), want 0", m6.(Model).cursor)
|
|
}
|
|
}
|
|
|
|
func TestModelJKKeys(t *testing.T) {
|
|
m := newTestModel()
|
|
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
|
|
if m2.(Model).cursor != 1 {
|
|
t.Errorf("cursor = %d after j, want 1", m2.(Model).cursor)
|
|
}
|
|
m3, _ := m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
|
|
if m3.(Model).cursor != 0 {
|
|
t.Errorf("cursor = %d after k, want 0", m3.(Model).cursor)
|
|
}
|
|
}
|
|
|
|
func TestModelDetailToggle(t *testing.T) {
|
|
m := newTestModel()
|
|
if m.detail {
|
|
t.Error("detail = true initially, want false")
|
|
}
|
|
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
if !m2.(Model).detail {
|
|
t.Error("detail = false after Enter, want true")
|
|
}
|
|
m3, _ := m2.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
if m3.(Model).detail {
|
|
t.Error("detail = true after Space, want false")
|
|
}
|
|
}
|
|
|
|
func TestModelHomeEndKeys(t *testing.T) {
|
|
m := newTestModel()
|
|
// Start at 0, go to end with G
|
|
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
|
|
if m2.(Model).cursor != 2 {
|
|
t.Errorf("cursor = %d after G, want 2 (last)", m2.(Model).cursor)
|
|
}
|
|
// Go to home with g
|
|
m3, _ := m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
|
|
if m3.(Model).cursor != 0 {
|
|
t.Errorf("cursor = %d after g, want 0 (home)", m3.(Model).cursor)
|
|
}
|
|
}
|
|
|
|
func TestModelHelpToggle(t *testing.T) {
|
|
m := newTestModel()
|
|
if m.help {
|
|
t.Error("help = true initially, want false")
|
|
}
|
|
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
|
|
if !m2.(Model).help {
|
|
t.Error("help = false after ?, want true")
|
|
}
|
|
}
|
|
|
|
func TestModelRescanR(t *testing.T) {
|
|
m := newTestModel()
|
|
if m.loading {
|
|
t.Error("loading = true initially, want false")
|
|
}
|
|
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
|
|
if cmd == nil {
|
|
t.Error("'r' produced nil cmd, want scanCmd")
|
|
}
|
|
}
|
|
|
|
func TestModelChangedMarkers(t *testing.T) {
|
|
m := newTestModel()
|
|
m.first = false
|
|
m.changed = map[string]bool{"/tmp/test/b": true}
|
|
view := m.View()
|
|
if !strings.Contains(view, "▲") {
|
|
t.Errorf("view missing ▲ marker for changed repo:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestModelIntervalTickInitiatesScan(t *testing.T) {
|
|
m := newTestModel()
|
|
m.interval = 10 * time.Second
|
|
m.first = false
|
|
m.loading = false
|
|
|
|
// A tick fires a scan when not already loading.
|
|
_ = m
|
|
_, cmd := m.Update(scanTickMsg{time.Now()})
|
|
if cmd == nil {
|
|
t.Error("scanTickMsg produced nil cmd, want scanCmd")
|
|
}
|
|
}
|
|
|
|
func TestModelIntervalShownInStatusBar(t *testing.T) {
|
|
m := newTestModel()
|
|
m.interval = 5 * time.Second
|
|
m.lastScan = time.Now()
|
|
view := m.View()
|
|
if !strings.Contains(view, "next:") {
|
|
t.Errorf("status bar missing countdown:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestModelQuit(t *testing.T) {
|
|
m := newTestModel()
|
|
for _, key := range []tea.KeyType{tea.KeyCtrlC} {
|
|
_, cmd := m.Update(tea.KeyMsg{Type: key})
|
|
if cmd == nil {
|
|
t.Errorf("no quit cmd for key %v", key)
|
|
continue
|
|
}
|
|
// tea.Quit is a function that returns a tea.QuitMsg
|
|
if cmd() == nil {
|
|
t.Errorf("quit cmd produced nil msg")
|
|
}
|
|
}
|
|
// 'q' quit
|
|
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
|
if cmd == nil {
|
|
t.Error("no quit cmd for 'q'")
|
|
}
|
|
}
|
|
|
|
func TestModelViewRendersRepos(t *testing.T) {
|
|
m := newTestModel()
|
|
view := m.View()
|
|
for _, want := range []string{"a", "b", "c", "feat/x", "STATUS", "BRANCH"} {
|
|
if !strings.Contains(view, want) {
|
|
t.Errorf("view missing %q:\n%s", want, view)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestModelViewShowsHelp(t *testing.T) {
|
|
m := newTestModel()
|
|
m.help = true
|
|
view := m.View()
|
|
if !strings.Contains(view, "KEYBINDINGS") {
|
|
t.Errorf("help view missing KEYBINDINGS:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestModelViewShowsDetail(t *testing.T) {
|
|
m := newTestModel()
|
|
m.cursor = 1 // select repo 'b'
|
|
m.detail = true
|
|
view := m.View()
|
|
if !strings.Contains(view, "modified:") || !strings.Contains(view, "f.go") {
|
|
t.Errorf("detail view missing file:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestModelViewLoading(t *testing.T) {
|
|
m := newTestModel()
|
|
m.loading = true
|
|
view := m.View()
|
|
if !strings.Contains(view, "scanning") {
|
|
t.Errorf("loading view missing 'scanning':\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestModelViewEmpty(t *testing.T) {
|
|
m := newTestModelWith(status.ScanResult{Repos: nil})
|
|
view := m.View()
|
|
if !strings.Contains(view, "no repositories") {
|
|
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")
|
|
}
|
|
}
|