merge: M4 TUI AI into main

This commit is contained in:
dimitar 2026-08-02 09:13:01 +02:00
commit c7c21e76b7
2 changed files with 139 additions and 0 deletions

View File

@ -30,6 +30,7 @@ type Model struct {
detail bool detail bool
help bool help bool
aiPanel bool aiPanel bool
aiSugs []ai.Suggestion
width int width int
height int height int
@ -99,6 +100,12 @@ type scanResultMsg struct{ result status.ScanResult }
// scanErrorMsg carries a failed scan. // scanErrorMsg carries a failed scan.
type scanErrorMsg struct{ err error } 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. // scanCmd triggers a scan in the background.
func (m Model) scanCmd() tea.Msg { func (m Model) scanCmd() tea.Msg {
result, err := m.app.ScanOnce(context.Background()) result, err := m.app.ScanOnce(context.Background())
@ -108,6 +115,18 @@ func (m Model) scanCmd() tea.Msg {
return scanResultMsg{result} 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 ----------------------------------------------------------------
// Update handles incoming messages. // Update handles incoming messages.
@ -144,6 +163,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.loading = true m.loading = true
return m, m.scanCmd 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: case scanTickMsg:
@ -187,6 +213,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return scanTickMsg{t} 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 return m, nil
} }
@ -206,6 +242,10 @@ func (m Model) View() string {
b.WriteString(m.detailView(m.result.Repos[m.cursor])) b.WriteString(m.detailView(m.result.Repos[m.cursor]))
b.WriteString("\n") b.WriteString("\n")
} }
if m.aiPanel {
b.WriteString(m.aiPanelView())
b.WriteString("\n")
}
b.WriteString(m.statusBarView()) b.WriteString(m.statusBarView())
return b.String() return b.String()
} }
@ -340,10 +380,46 @@ func (m Model) statusBarView() string {
left += " | scanning…" left += " | scanning…"
} }
right := "r refresh ? help q quit ↑↓ nav Enter detail" 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 bar := left + strings.Repeat(" ", max(0, m.width-len(left)-len(right))) + right
return m.styles.StatusBar.Render(bar) 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 ------------------------------------------------------------------ // ---- help ------------------------------------------------------------------
func (m Model) helpView() string { func (m Model) helpView() string {

View File

@ -1,12 +1,14 @@
package tui package tui
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbletea"
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config" "gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter" "gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status" "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) 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")
}
}