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 }