689 lines
20 KiB
Go
689 lines
20 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
|
|
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 {
|
|
if m.aiCancel != nil {
|
|
m.aiCancel()
|
|
m.aiCancel = nil
|
|
}
|
|
m.aiMode, m.aiInput, m.writeThreadID, m.aiEvents = aiNone, "", "", nil
|
|
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
|
|
}
|
|
case keyMatches(raw, m.keybindings.Input.Newline):
|
|
m.aiInput += "\n"
|
|
case keyMatches(raw, m.keybindings.Input.DeleteBackward):
|
|
runes := []rune(m.aiInput)
|
|
if len(runes) > 0 {
|
|
m.aiInput = string(runes[:len(runes)-1])
|
|
}
|
|
default:
|
|
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
|
|
m.aiInput += textInputKeyValue(key)
|
|
}
|
|
}
|
|
m.scroll = m.detailMaxScroll()
|
|
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.aiInput, 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 cancel",
|
|
primaryKeyLabel(m.keybindings.Input.Newline),
|
|
primaryKeyLabel(m.keybindings.Input.Submit),
|
|
primaryKeyLabel(m.keybindings.Input.Cancel),
|
|
)))
|
|
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.aiInput, 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 cancel",
|
|
primaryKeyLabel(m.keybindings.Input.Newline),
|
|
primaryKeyLabel(m.keybindings.Input.Submit),
|
|
primaryKeyLabel(m.keybindings.Input.Cancel),
|
|
)),
|
|
})
|
|
return lines
|
|
}
|