experimental: codex / ai integration
This commit is contained in:
631
ai_tui.go
Normal file
631
ai_tui.go
Normal file
@@ -0,0 +1,631 @@
|
||||
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
|
||||
m.aiProgress = AIRunProgress{
|
||||
Stage: "Preparing local AI review",
|
||||
Summary: "Checking the provider and loading the authenticated GitHub diff",
|
||||
}
|
||||
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
|
||||
m.aiProgress = AIRunProgress{
|
||||
Stage: "Starting local AI review", Model: preview.Model,
|
||||
CurrentCall: 1, TotalCalls: preview.Calls,
|
||||
}
|
||||
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
|
||||
m.aiProgress = AIRunProgress{
|
||||
Stage: "Testing provider", Summary: "Preparing one minimal structured model call",
|
||||
CurrentCall: 1, TotalCalls: 1,
|
||||
}
|
||||
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
|
||||
m.recordHealth("AI provider", healthOK, fmt.Sprintf(
|
||||
"local review complete: %d findings, %d thread comments",
|
||||
msg.result.Findings, msg.result.Comments,
|
||||
))
|
||||
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 += string(key.Runes)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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."), "")
|
||||
draft := m.aiInput + "│"
|
||||
for _, source := range strings.Split(draft, "\n") {
|
||||
lines = append(lines, strings.Split(ansi.Wordwrap(source, width-2, ""), "\n")...)
|
||||
}
|
||||
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."),
|
||||
"",
|
||||
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.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 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 {
|
||||
if comment.Origin != reviewOriginLocalAI {
|
||||
return ""
|
||||
}
|
||||
return " " + warnStyle.Render("[LOCAL AI · LOCAL ONLY]")
|
||||
}
|
||||
|
||||
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]"),
|
||||
},
|
||||
}
|
||||
draft := m.aiInput + "│"
|
||||
textWidth := max(1, width-4)
|
||||
lineIndex := 0
|
||||
for _, sourceLine := range strings.Split(draft, "\n") {
|
||||
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, textWidth, ""), textWidth, false)
|
||||
for _, part := range strings.Split(wrapped, "\n") {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user