experimental: codex / ai integration
This commit is contained in:
118
tui.go
118
tui.go
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user