experimental: codex / ai integration

This commit is contained in:
2026-07-28 20:48:00 +02:00
parent e89c524438
commit 949935a2aa
14 changed files with 2987 additions and 7 deletions

118
tui.go
View File

@@ -173,6 +173,19 @@ type App struct {
initializedPRs map[string]bool
unreadThreads map[string]bool
updatedThreads map[string]bool
ai *AIController
aiStore *AIStore
aiMode aiMode
aiMenuIndex int
aiInput string
aiPreview AIPreview
aiCancel context.CancelFunc
aiStatus AIProviderStatus
aiStatusBusy bool
aiPreviewScroll int
aiProgress AIRunProgress
aiSpinner int
aiEvents <-chan tea.Msg
}
type AppSettings struct {
@@ -188,6 +201,8 @@ type AppSettings struct {
KeyBindings KeyBindings
ReadState *readStateStore
Drafts *draftStore
AI *AIController
AIStore *AIStore
}
func defaultAppSettings() AppSettings {
@@ -238,6 +253,7 @@ func NewAppWithSettings(
knownThreads: make(map[string]bool), knownComments: make(map[string]bool),
initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool),
updatedThreads: make(map[string]bool),
ai: settings.AI, aiStore: settings.AIStore,
}
}
@@ -357,6 +373,10 @@ func (m App) loadDetailsEnrichment(details PRDetails) tea.Cmd {
func (m *App) startReply() {
thread := m.selectedThread()
if thread != nil && thread.Origin == reviewOriginLocalAI {
m.startAIDiscussion(thread.ID)
return
}
if reason := m.writeActionUnavailable("reply", thread); reason != "" {
m.err = errors.New(reason)
return
@@ -483,6 +503,18 @@ func mergeNowStateReason(pr PRDetails) string {
}
func (m App) writeActionUnavailable(action string, thread *ReviewThread) string {
if thread == nil {
return "select a review thread first"
}
if thread.Origin == reviewOriginLocalAI {
if m.aiStore == nil {
return "local AI state is unavailable"
}
if action == "reply" && (m.ai == nil || !m.ai.config.Enabled) {
return "AI discussion is unavailable because AI integration is disabled"
}
return ""
}
if m.loading {
return "write action unavailable while PR data is refreshing"
}
@@ -492,9 +524,6 @@ func (m App) writeActionUnavailable(action string, thread *ReviewThread) string
if _, ok := m.service.(GitHubWriteService); !ok {
return "configured GitHub service does not support write actions"
}
if thread == nil {
return "select a review thread first"
}
switch action {
case "reply":
if !thread.ViewerCanReply {
@@ -598,6 +627,15 @@ func (m App) submitReply() tea.Cmd {
}
func (m App) submitResolution() tea.Cmd {
if thread := m.threadByID(m.writeThreadID); thread != nil &&
thread.Origin == reviewOriginLocalAI {
store, details := m.aiStore, m.details
threadID, resolved := m.writeThreadID, m.resolveTarget
return func() tea.Msg {
thread, err := store.SetResolved(details, threadID, resolved)
return threadResolvedMsg{threadID: threadID, thread: thread, err: err}
}
}
writer := m.service.(GitHubWriteService)
threadID, resolved := m.writeThreadID, m.resolveTarget
return func() tea.Msg {
@@ -631,6 +669,11 @@ func (m App) submitMergeNow() tea.Cmd {
}
func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.aiMode != aiNone {
if updated, command, handled := m.updateAI(msg); handled {
return updated, command
}
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
@@ -728,7 +771,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
selected = m.details.Threads[m.threadIndex].ID
anchor = m.detailScrollAnchor()
}
msg.details = preservePartialPRData(msg.details, m.details)
msg.details = preservePartialPRData(msg.details, withoutLocalAI(m.details))
if m.aiStore != nil {
if state, loadErr := m.aiStore.Load(msg.details); loadErr != nil {
m.recordHealth("local AI state", healthWarning, loadErr.Error())
} else {
msg.details = state.Merge(msg.details)
}
}
m.trackThreadUpdates(msg.details)
sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus)
m.details = msg.details
@@ -1041,6 +1091,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
switch k {
case "A":
m.openAIMenu()
case "c":
if m.screen == threadScreen {
m.startReply()
@@ -1696,6 +1748,9 @@ func (m App) View() string {
if m.helpVisible {
return m.viewHelp()
}
if m.aiMode != aiNone && m.aiMode != aiDiscussion {
return m.viewAI()
}
if m.writeMode != writeNone && m.writeMode != writeReply {
if m.writeMode == writePREdit {
return m.viewDashboard()
@@ -1959,6 +2014,7 @@ func (m App) helpBindings() []helpBinding {
{keyLabel(m.keybindings.Views.Edit), "Edit title, target branch, and description"},
{keyLabel(m.keybindings.Views.AutoMerge), "Enable or disable auto-merge"},
{keyLabel(m.keybindings.Views.MergeNow), "Merge the pull request now when all requirements are met"},
{keyLabel(m.keybindings.Views.AI), "Open the local AI review menu"},
{keyLabel(m.keybindings.Views.Open), "Open review threads"},
{keyLabel(m.keybindings.General.Back), backAction},
{keyLabel(m.keybindings.General.Refresh), "Refresh now"},
@@ -1981,6 +2037,7 @@ func (m App) helpBindings() []helpBinding {
{combinedKeyLabel(m.keybindings.Threads.NextUnread, m.keybindings.Threads.PreviousUnread), "Next / previous new update"},
{keyLabel(m.keybindings.Threads.Reply), "Compose a reply to the selected thread"},
{keyLabel(m.keybindings.Threads.Resolve), "Resolve or unresolve the selected thread"},
{keyLabel(m.keybindings.Views.AI), "Open the local AI review or selected-thread discussion menu"},
{keyLabel(m.keybindings.Views.Dashboard), "Open pull request dashboard"},
{combinedKeyLabel(
m.keybindings.Threads.Toggle,
@@ -2218,9 +2275,10 @@ func (m App) viewDashboard() string {
scroll := min(m.scroll, maxScroll)
visible := lines[scroll:min(len(lines), scroll+viewportHeight)]
footer := fmt.Sprintf(
"%s keys • %s scroll • %s threads • %s back • %s quit",
"%s keys • %s scroll • %s AI • %s threads • %s back • %s quit",
primaryKeyLabel(m.keybindings.General.Help),
primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up),
primaryKeyLabel(m.keybindings.Views.AI),
primaryKeyLabel(m.keybindings.Views.Open),
primaryKeyLabel(m.keybindings.General.Back),
primaryKeyLabel(m.keybindings.General.Quit),
@@ -2340,6 +2398,37 @@ func (m App) healthLines() []string {
}
components = append(components, component)
}
if m.ai == nil || !m.ai.config.Enabled {
components = append(components, HealthComponent{
Name: "AI integration", Level: healthInfo,
Summary: "disabled by configuration; no PR data is sent to a model",
})
} else {
status := m.aiStatus
if status.Summary == "" {
status.Summary = "enabled; provider status not checked yet"
status.Detail = "open the AI menu to probe Codex authentication and model availability"
}
level := healthOK
if !status.Ready {
level = healthWarning
}
components = append(components, HealthComponent{
Name: "AI provider", Level: level, Summary: status.Summary,
Detail: strings.TrimSpace(status.Detail + " model " + status.Model),
})
if m.aiStore != nil {
storeHealth := HealthComponent{
Name: "local AI state", Level: healthOK,
Summary: "atomic per-PR storage is available", Detail: m.aiStore.dir,
}
if m.aiStore.loadErr != nil {
storeHealth.Level = healthWarning
storeHealth.Summary = m.aiStore.loadErr.Error()
}
components = append(components, storeHealth)
}
}
if m.details.ID != "" {
core := HealthComponent{
Name: "PR core data", Level: healthOK,
@@ -2902,10 +2991,11 @@ func (m App) viewThreads() string {
body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
}
help := fmt.Sprintf(
"%s keys • %s focus • %s move/scroll • %s reply • %s resolve • %s dashboard • %s back • %s quit",
"%s keys • %s focus • %s move/scroll • %s AI • %s reply • %s resolve • %s dashboard • %s back • %s quit",
primaryKeyLabel(m.keybindings.General.Help),
primaryCombinedKeyLabel(m.keybindings.Navigation.Left, m.keybindings.Navigation.Right),
primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up),
primaryKeyLabel(m.keybindings.Views.AI),
primaryKeyLabel(m.keybindings.Threads.Reply),
primaryKeyLabel(m.keybindings.Threads.Resolve),
primaryKeyLabel(m.keybindings.Views.Dashboard),
@@ -2925,6 +3015,13 @@ func (m App) viewThreads() string {
primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel),
)
} else if m.aiMode == aiDiscussion {
help = fmt.Sprintf(
"local AI discussion • %s newline • %s prepare • %s cancel",
primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel),
)
}
return m.frame(append(top, body), help)
}
@@ -3034,6 +3131,9 @@ func (m App) detailLines(width int) []detailLine {
if thread.IsOutdated {
status += ", outdated"
}
if thread.Origin == reviewOriginLocalAI {
status += ", LOCAL AI · LOCAL ONLY"
}
if m.unreadThreads[thread.ID] {
status += ", new updates"
}
@@ -3076,7 +3176,8 @@ func (m App) detailLines(width int) []detailLine {
lines = append(lines, detailLine{}, detailLine{
rail: rail,
anchor: "comment:" + comment.ID + ":header",
text: authorStyle(comment.Author).Render("@"+comment.Author) + " " +
text: authorStyle(comment.Author).Render("@"+comment.Author) +
localAICommentBadge(comment) + " " +
dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")),
})
if content.Prose != "" {
@@ -3126,6 +3227,9 @@ func (m App) detailLines(width int) []detailLine {
if m.writeMode == writeReply && m.writeThreadID == thread.ID {
lines = append(lines, m.inlineReplyLines(width)...)
}
if m.aiMode == aiDiscussion && m.writeThreadID == thread.ID {
lines = append(lines, m.inlineAIDiscussionLines(width)...)
}
return lines
}