merge: M4 TUI AI into main
This commit is contained in:
commit
c7c21e76b7
@ -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 {
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user