Files
diple/ai_tui.go

695 lines
21 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
type aiMode int
const (
aiNone aiMode = iota
aiMenu
aiDiscussion
aiPreparing
aiConfirm
aiBusy
aiProviderTestConfirm
aiProviderTestBusy
)
type aiPreparedMsg struct {
preview AIPreview
err error
}
type aiCompletedMsg struct {
result AIResult
err error
}
type aiStatusMsg struct {
status AIProviderStatus
}
type aiProgressMsg struct {
progress AIRunProgress
}
type aiProviderTestCompletedMsg struct {
model string
err error
}
type aiAnimationTickMsg time.Time
func (m *App) openAIMenu() {
if m.screen == prScreen {
m.err = errors.New("open a pull request before starting an AI review")
return
}
m.aiMode, m.aiMenuIndex, m.aiInput, m.err = aiMenu, 0, "", nil
if m.ai == nil || !m.ai.config.Enabled {
m.aiStatus = AIProviderStatus{
Summary: "disabled by configuration",
Detail: "set ai.enabled = true to enable local AI review",
}
}
}
func (m *App) startAIDiscussion(threadID string) {
if m.ai == nil || !m.ai.config.Enabled {
m.err = errors.New("AI discussion is unavailable because AI integration is disabled")
return
}
m.aiMode, m.aiInput, m.writeThreadID, m.err = aiDiscussion, "", threadID, nil
m.resetInputEditor(&m.aiInputEditor, "")
if thread := m.threadByID(threadID); thread != nil {
m.folded[threadID] = false
m.focus = threadDetailPane
m.scroll = m.detailMaxScroll()
}
}
func (m *App) beginAIPrepare(threadID, message string) tea.Cmd {
if m.ai == nil || !m.ai.config.Enabled {
m.err = errors.New("AI integration is disabled; set ai.enabled = true")
m.aiMode = aiMenu
return nil
}
if m.loading {
m.err = errors.New("AI preparation is unavailable while PR data is refreshing")
m.aiMode = aiMenu
return nil
}
if m.details.FromCache {
m.err = errors.New("AI preparation requires current live PR data, not a cached snapshot")
m.aiMode = aiMenu
return nil
}
ctx, cancel := context.WithCancel(context.Background())
m.aiCancel = cancel
controller, details := m.ai, m.details
m.aiMode = aiPreparing
m.aiSpinner = 0
started := time.Now()
summary := "Checking the provider and loading the authenticated GitHub diff"
if threadID != "" {
summary = "Checking the provider and loading exact-head repository context"
}
m.aiProgress = AIRunProgress{
Stage: "Preparing local AI review",
Summary: summary, StartedAt: started, StageStartedAt: started,
}
prepare := func() tea.Msg {
preview, err := controller.Prepare(ctx, details, threadID, message)
return aiPreparedMsg{preview: preview, err: err}
}
return tea.Batch(prepare, nextAIAnimationTick())
}
func (m *App) beginAIRun() tea.Cmd {
if m.details.HeadOID != m.aiPreview.HeadOID {
m.err = errors.New("PR head changed after preparation; prepare the AI review again")
m.aiMode = aiMenu
return nil
}
ctx, cancel := context.WithCancel(context.Background())
m.aiCancel = cancel
controller, preview := m.ai, m.aiPreview
m.aiMode = aiBusy
m.aiSpinner = 0
started := time.Now()
m.aiProgress = AIRunProgress{
Stage: "Starting local AI review", Model: preview.Model,
CurrentCall: 1, TotalCalls: preview.Calls,
StartedAt: started, StageStartedAt: started,
}
events := make(chan tea.Msg, 64)
m.aiEvents = events
work := func() tea.Msg {
go func() {
result, err := controller.RunWithProgress(ctx, preview, func(progress AIRunProgress) {
select {
case events <- aiProgressMsg{progress: progress}:
default:
}
})
events <- aiCompletedMsg{result: result, err: err}
close(events)
}()
return <-events
}
return tea.Batch(work, nextAIAnimationTick())
}
func (m *App) beginAIProviderTest() tea.Cmd {
if m.ai == nil || !m.ai.config.Enabled {
m.err = errors.New("AI integration is disabled; set ai.enabled = true")
m.aiMode = aiMenu
return nil
}
ctx, cancel := context.WithCancel(context.Background())
m.aiCancel = cancel
controller := m.ai
m.aiMode = aiProviderTestBusy
m.aiSpinner = 0
started := time.Now()
m.aiProgress = AIRunProgress{
Stage: "Testing provider", Summary: "Preparing one minimal structured model call",
CurrentCall: 1, TotalCalls: 1,
StartedAt: started, StageStartedAt: started,
}
events := make(chan tea.Msg, 32)
m.aiEvents = events
work := func() tea.Msg {
go func() {
model, err := controller.TestProvider(ctx, func(progress AIRunProgress) {
select {
case events <- aiProgressMsg{progress: progress}:
default:
}
})
events <- aiProviderTestCompletedMsg{model: model, err: err}
close(events)
}()
return <-events
}
return tea.Batch(work, nextAIAnimationTick())
}
func waitAIEvent(events <-chan tea.Msg) tea.Cmd {
if events == nil {
return nil
}
return func() tea.Msg {
return <-events
}
}
func nextAIAnimationTick() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(at time.Time) tea.Msg {
return aiAnimationTickMsg(at)
})
}
func (m App) updateAI(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return m, nil, false
case aiPreparedMsg:
if m.aiMode != aiPreparing {
return m, nil, true
}
m.aiCancel = nil
if msg.err != nil {
m.err, m.aiMode = msg.err, aiMenu
} else {
m.aiPreview, m.aiMode, m.aiPreviewScroll, m.err = msg.preview, aiConfirm, 0, nil
m.aiStatus = AIProviderStatus{
Ready: true, Summary: "ready via authenticated provider", Model: msg.preview.Model,
}
}
return m, nil, true
case aiAnimationTickMsg:
switch m.aiMode {
case aiPreparing, aiBusy, aiProviderTestBusy:
m.aiSpinner++
return m, nextAIAnimationTick(), true
default:
return m, nil, true
}
case aiProgressMsg:
if m.aiMode != aiBusy && m.aiMode != aiProviderTestBusy {
return m, nil, true
}
m.aiProgress = msg.progress
return m, waitAIEvent(m.aiEvents), true
case aiStatusMsg:
m.aiStatus, m.aiStatusBusy = msg.status, false
return m, nil, true
case aiCompletedMsg:
if m.aiMode != aiBusy {
return m, nil, true
}
m.aiCancel, m.aiEvents = nil, nil
if msg.err != nil {
m.err, m.aiMode = msg.err, aiMenu
m.recordHealth("AI provider", healthError, msg.err.Error())
return m, nil, true
}
if m.details.HeadOID != m.aiPreview.HeadOID {
m.err = errors.New("AI result was saved locally but the visible PR head changed; refresh to inspect it as outdated")
}
if m.aiStore != nil {
if state, err := m.aiStore.Load(m.details); err == nil {
m.details = state.Merge(withoutLocalAI(m.details))
sortReviewThreads(m.details.Threads, m.threadStatusOrder, m.threadWithinStatus)
}
}
m.aiMode = aiNone
healthMessage := fmt.Sprintf(
"local review complete: %d findings, %d thread comments",
msg.result.Findings, msg.result.Comments,
)
if timing := msg.result.Timing; timing.Total > 0 {
healthMessage += fmt.Sprintf(
" in %s (GitHub %s, filtering %s, provider %s across %d call(s), %d requested file(s))",
formatAIDuration(timing.Total), formatAIDuration(timing.GitHub),
formatAIDuration(timing.Filtering), formatAIDuration(timing.Provider),
timing.Calls, timing.Files,
)
}
m.recordHealth("AI provider", healthOK, healthMessage)
return m, nil, true
case aiProviderTestCompletedMsg:
if m.aiMode != aiProviderTestBusy {
return m, nil, true
}
m.aiCancel, m.aiEvents = nil, nil
if msg.err != nil {
m.err, m.aiMode = msg.err, aiMenu
m.recordHealth("AI provider", healthError, msg.err.Error())
return m, nil, true
}
m.aiStatus = AIProviderStatus{
Ready: true, Summary: "minimal inference test passed", Model: msg.model,
}
m.err, m.aiMode = nil, aiMenu
m.recordHealth("AI provider", healthOK, "minimal inference test passed using "+msg.model)
return m, nil, true
}
key, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil, false
}
raw := key.String()
if keyMatches(raw, m.keybindings.General.Quit) && key.Type != tea.KeyRunes {
return m, tea.Quit, true
}
cancelled := keyMatches(raw, m.keybindings.Input.Cancel)
if m.aiMode != aiDiscussion {
cancelled = cancelled || keyMatches(raw, m.keybindings.General.Back)
}
if cancelled && m.aiMode == aiDiscussion &&
m.aiInputEditor.Modal && m.aiInputEditor.Mode != textEditorNormal {
m.aiInputEditor.handleKeyAtWidth(key, true, m.threadInputWidth())
m.aiInput = m.aiInputEditor.Text
m.ensureThreadInputCursorVisible()
return m, nil, true
}
if cancelled {
if m.aiCancel != nil {
m.aiCancel()
m.aiCancel = nil
}
m.aiMode, m.aiInput, m.writeThreadID, m.aiEvents = aiNone, "", "", nil
m.aiInputEditor = textEditor{}
return m, nil, true
}
switch m.aiMode {
case aiMenu:
switch {
case keyMatches(raw, m.keybindings.Navigation.Down):
m.aiMenuIndex = min(3, m.aiMenuIndex+1)
case keyMatches(raw, m.keybindings.Navigation.Up):
m.aiMenuIndex = max(0, m.aiMenuIndex-1)
case keyMatches(raw, m.keybindings.Views.Open), keyMatches(raw, m.keybindings.Input.Newline):
switch m.aiMenuIndex {
case 0:
return m, m.beginAIPrepare("", ""), true
case 1:
thread := m.selectedThread()
if m.screen != threadScreen || thread == nil {
m.err = errors.New("select a thread before starting a local AI discussion")
} else {
m.startAIDiscussion(thread.ID)
}
case 2:
if m.ai != nil && !m.aiStatusBusy {
controller := m.ai
m.aiStatusBusy = true
return m, func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return aiStatusMsg{status: controller.Status(ctx)}
}, true
}
case 3:
if m.ai == nil || !m.ai.config.Enabled {
m.err = errors.New("AI integration is disabled; set ai.enabled = true")
} else {
m.aiMode, m.err = aiProviderTestConfirm, nil
}
}
}
case aiDiscussion:
switch {
case keyMatches(raw, m.keybindings.Input.Submit):
if strings.TrimSpace(m.aiInput) == "" {
m.err = errors.New("AI discussion message cannot be empty")
} else {
return m, m.beginAIPrepare(m.writeThreadID, strings.TrimSpace(m.aiInput)), true
}
default:
if m.aiInputEditor.handleKeyAtWidth(key, true, m.threadInputWidth()) {
m.aiInput = m.aiInputEditor.Text
}
}
m.ensureThreadInputCursorVisible()
case aiConfirm:
switch {
case keyMatches(raw, m.keybindings.General.Confirm):
return m, m.beginAIRun(), true
case keyMatches(raw, m.keybindings.General.Reject):
m.aiMode = aiMenu
case keyMatches(raw, m.keybindings.Navigation.Down):
m.aiPreviewScroll++
case keyMatches(raw, m.keybindings.Navigation.Up):
m.aiPreviewScroll = max(0, m.aiPreviewScroll-1)
case keyMatches(raw, m.keybindings.Navigation.PageDown):
m.aiPreviewScroll += max(1, m.height/2)
case keyMatches(raw, m.keybindings.Navigation.PageUp):
m.aiPreviewScroll = max(0, m.aiPreviewScroll-max(1, m.height/2))
}
case aiProviderTestConfirm:
switch {
case keyMatches(raw, m.keybindings.General.Confirm):
return m, m.beginAIProviderTest(), true
case keyMatches(raw, m.keybindings.General.Reject):
m.aiMode = aiMenu
}
case aiPreparing, aiBusy, aiProviderTestBusy:
// Only cancellation is accepted while provider work is in flight.
}
return m, nil, true
}
func withoutLocalAI(pr PRDetails) PRDetails {
result := pr
result.Threads = make([]ReviewThread, 0, len(pr.Threads))
for _, thread := range pr.Threads {
if thread.Origin == reviewOriginLocalAI {
continue
}
thread.Comments = append([]ReviewComment(nil), thread.Comments...)
thread.Comments = slicesDeleteLocalAIComments(thread.Comments)
result.Threads = append(result.Threads, thread)
}
return result
}
func slicesDeleteLocalAIComments(comments []ReviewComment) []ReviewComment {
result := comments[:0]
for _, comment := range comments {
if comment.Origin != reviewOriginLocalAI &&
comment.Origin != reviewOriginLocalAIUser {
result = append(result, comment)
}
}
return result
}
func (m App) viewAI() string {
width := max(30, min(76, m.width-4))
var lines []string
fixedFooter := ""
switch m.aiMode {
case aiMenu:
lines = append(lines, titleStyle.Render("Local AI review"), "")
options := []string{
"Review this pull request",
"Discuss the selected thread",
"Refresh provider status (no model call)",
"Test provider (one minimal model call)",
}
for index, option := range options {
line := " " + option
if index == m.aiMenuIndex {
line = activeStyle.Render(line)
}
lines = append(lines, line)
}
lines = append(lines, "")
status := m.aiStatus
summary := firstNonEmpty(status.Summary, "not checked")
if m.aiStatusBusy {
summary = "checking provider status…"
}
lines = append(lines, dimStyle.Render("Provider: "+summary))
if status.Model != "" {
lines = append(lines, dimStyle.Render("Model: "+status.Model))
}
if m.err != nil {
lines = append(lines, badStyle.Render(m.err.Error()))
}
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
"%s/%s move • %s select • %s close",
primaryKeyLabel(m.keybindings.Navigation.Down),
primaryKeyLabel(m.keybindings.Navigation.Up),
primaryKeyLabel(m.keybindings.Views.Open),
primaryKeyLabel(m.keybindings.General.Back),
)))
case aiDiscussion:
lines = append(lines, titleStyle.Render("Local AI discussion"), "",
dimStyle.Render("This message stays local; only the configured model receives it."), "")
lines = append(lines, renderTextInput(
m.aiInputEditor, width-2, m.cursorOutput != nil,
)...)
if m.err != nil {
lines = append(lines, "", badStyle.Render(m.err.Error()))
}
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
"%s newline • %s prepare • %s %s",
primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel),
m.inputCancelAction(),
)))
case aiPreparing:
lines = m.aiProgressLines(width)
case aiConfirm:
lines = []string{
titleStyle.Render("Send this review context to " + m.ai.provider.Name() + "?"), "",
fmt.Sprintf("%d files • %d bytes • at most %d model call(s)",
m.aiPreview.Files, m.aiPreview.Bytes, m.aiPreview.Calls),
fmt.Sprintf("Model: %s • head: %s", m.aiPreview.Model, shortOID(m.aiPreview.HeadOID)),
fmt.Sprintf("%d secret-like value(s) redacted • %d file(s) excluded",
m.aiPreview.Redactions, len(m.aiPreview.Excluded)),
"",
warnStyle.Render("Code and PR discussion will leave GitHub. No local files or commands are available to the model."),
}
if m.aiPreview.thread != nil {
treeStatus := fmt.Sprintf(
"%d visible tree entries • %d unavailable • %d sensitive hidden",
m.aiPreview.TreeEntries, m.aiPreview.TreeUnavailable, m.aiPreview.TreeHidden,
)
if m.aiPreview.TreeTruncated {
treeStatus += " • truncated"
}
lines = append(lines,
"",
fmt.Sprintf("Initial file revision: %s", shortOID(m.aiPreview.InitialRevision)),
treeStatus,
fmt.Sprintf(
"Confirmation allows up to %d automatic request round(s) and %d additional file(s).",
m.aiPreview.ContextRounds, m.aiPreview.ContextFiles,
),
)
}
lines = append(lines, "", titleStyle.Render("Included files"))
for _, path := range m.aiPreview.Included {
lines = append(lines, " "+path)
}
if len(m.aiPreview.Excluded) > 0 {
lines = append(lines, "", titleStyle.Render("Excluded files"))
for _, path := range m.aiPreview.Excluded {
lines = append(lines, " "+path)
}
}
fixedFooter = dimStyle.Render(fmt.Sprintf(
"%s/%s scroll • %s run • %s cancel",
primaryKeyLabel(m.keybindings.Navigation.Down),
primaryKeyLabel(m.keybindings.Navigation.Up),
primaryKeyLabel(m.keybindings.General.Confirm),
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
))
case aiBusy:
lines = m.aiProgressLines(width)
case aiProviderTestConfirm:
provider := "configured provider"
model := ""
if m.ai != nil {
provider = m.ai.provider.Name()
model = firstNonEmpty(m.ai.config.Model, m.aiStatus.Model)
}
lines = []string{
titleStyle.Render("Run a minimal inference test?"), "",
"This sends one tiny structured request to " + provider + ".",
warnStyle.Render("It consumes provider quota, but sends no PR contents or local files."),
}
if model != "" {
lines = append(lines, "Model: "+model)
}
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
"%s run test • %s cancel",
primaryKeyLabel(m.keybindings.General.Confirm),
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
)))
case aiProviderTestBusy:
lines = m.aiProgressLines(width)
}
var wrapped []string
for _, line := range lines {
wrapped = append(wrapped, strings.Split(ansi.Wordwrap(line, width-2, ""), "\n")...)
}
if fixedFooter != "" {
available := max(3, m.height-6)
start := clamp(m.aiPreviewScroll, 0, max(0, len(wrapped)-available))
end := min(len(wrapped), start+available)
wrapped = append(append([]string(nil), wrapped[start:end]...), "", fixedFooter)
}
popup := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).BorderForeground(paneActiveColor).
Padding(0, 1).Width(width).Render(strings.Join(wrapped, "\n"))
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup)
}
func (m App) aiProgressLines(width int) []string {
progress := m.aiProgress
title := firstNonEmpty(progress.Stage, "Working")
lines := []string{
titleStyle.Render(title + "…"), "",
renderAIProgressBar(max(12, width-8), progress, m.aiSpinner),
}
if progress.TotalCalls > 0 {
current := clamp(progress.CurrentCall, 1, progress.TotalCalls)
lines = append(lines, fmt.Sprintf(
"Model call %d/%d • %d complete",
current, progress.TotalCalls, progress.CompletedCalls,
))
}
if progress.Model != "" {
lines = append(lines, dimStyle.Render("Model: "+progress.Model))
}
if !progress.StartedAt.IsZero() {
elapsed := time.Since(progress.StartedAt)
stage := time.Duration(0)
if !progress.StageStartedAt.IsZero() {
stage = time.Since(progress.StageStartedAt)
}
timing := "Elapsed: " + formatAIDuration(elapsed)
if stage > 0 {
timing += " • current stage: " + formatAIDuration(stage)
}
lines = append(lines, dimStyle.Render(timing))
}
if progress.Summary != "" {
label := "Provider update"
if progress.SummaryKind == "reasoning" {
label = "Model reasoning summary"
}
lines = append(lines, "", titleStyle.Render(label), progress.Summary)
}
if m.aiMode == aiBusy {
lines = append(lines, "",
dimStyle.Render("The provider is tool-free; no repository commands can run."))
}
lines = append(lines, "", dimStyle.Render(
primaryKeyLabel(m.keybindings.Input.Cancel)+" cancel",
))
return lines
}
func formatAIDuration(value time.Duration) string {
if value < 0 {
value = 0
}
if value < time.Second {
return value.Round(10 * time.Millisecond).String()
}
return value.Round(100 * time.Millisecond).String()
}
func renderAIProgressBar(width int, progress AIRunProgress, spinner int) string {
width = max(8, width)
filled := 0
if progress.TotalCalls > 0 {
filled = clamp(width*progress.CompletedCalls/progress.TotalCalls, 0, width)
}
cells := make([]rune, width)
for index := range cells {
if index < filled {
cells[index] = '█'
} else {
cells[index] = '░'
}
}
if filled < width {
movingWidth := max(1, min(4, width-filled))
span := max(1, width-filled-movingWidth+1)
start := filled + spinner%span
for index := start; index < min(width, start+movingWidth); index++ {
cells[index] = '▓'
}
}
frames := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
return fmt.Sprintf("%c [%s]", frames[spinner%len(frames)], string(cells))
}
func localAICommentBadge(comment ReviewComment) string {
switch comment.Origin {
case reviewOriginLocalAI:
return " " + warnStyle.Render("[LOCAL AI · LOCAL ONLY]")
case reviewOriginLocalAIUser:
return " " + warnStyle.Render("[LOCAL ONLY]")
default:
return ""
}
}
func (m App) inlineAIDiscussionLines(width int) []detailLine {
rail := warnStyle.Render("│ ")
lines := []detailLine{
{},
{
rail: rail, anchor: "ai-discussion:header",
text: titleStyle.Render("Local AI discussion") + " " +
warnStyle.Render("[LOCAL ONLY]"),
},
}
textWidth := max(1, width-5)
lineIndex := 0
for _, part := range renderTextInput(
m.aiInputEditor, textWidth, m.cursorOutput != nil,
) {
lines = append(lines, detailLine{
rail: rail, anchor: fmt.Sprintf("ai-discussion:body:%d", lineIndex), text: part,
})
lineIndex++
}
if m.err != nil {
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())})
}
lines = append(lines, detailLine{
rail: rail,
text: dimStyle.Render(fmt.Sprintf(
"%s newline • %s prepare • %s %s",
primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel),
m.inputCancelAction(),
)),
})
return lines
}