package main import ( "context" "errors" "fmt" "hash/fnv" "slices" "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) type screen int const ( prScreen screen = iota dashboardScreen threadScreen healthScreen ) type pane int const ( threadListPane pane = iota threadDetailPane ) type tickMsg time.Time type pathTickMsg time.Time const mouseWheelScrollStep = 3 type writeMode int const ( writeNone writeMode = iota writeReply writeReplyConfirm writeReplyBusy writeResolveConfirm writeResolveBusy writePREdit writePREditConfirm writePREditBusy writeAutoMergeConfirm writeAutoMergeBusy writeMergeNowConfirm writeMergeNowBusy writeQueueBlocked ) type threadResolvedMsg struct { threadID string thread ReviewThread err error } type threadRepliedMsg struct { threadID string comment ReviewComment err error } type pullRequestUpdatedMsg struct { metadata PullRequestMetadata people PullRequestPeople peopleSaved bool err error } type autoMergeUpdatedMsg struct { request *AutoMergeRequest enabled bool err error } type pullRequestMergedMsg struct { result PullRequestMergeResult err error } type prsLoadedMsg struct { prs []PullRequest err error cached bool requestID uint64 } type detailsLoadedMsg struct { owner string repo string number int details PRDetails err error cached bool requestID uint64 } type branchesLoadedMsg struct { generation uint64 owner string repo string branches []RepositoryBranch err error } type repositoryUsersLoadedMsg struct { generation uint64 owner, repo string users []RepositoryUser err error } type detailsEnrichedMsg struct { enrichment PRDetailsEnrichment requestID uint64 } type App struct { service GitHubService owner, repo string showAll bool limit int poll time.Duration screen screen healthReturn screen healthScroll int healthEvents []HealthEvent requests *requestCoordinator prs []PullRequest prIndex int details PRDetails threadIndex int folded map[string]bool focus pane listHidden bool scroll int width, height int contentTop int headerWidth int loading bool secondaryLoading bool err error lastRefresh time.Time pendingZ bool searching bool searchQuery string searchEditor textEditor searchOrigin int helpVisible bool helpScroll int writeMode writeMode writeThreadID string replyDraft string replyEditor textEditor resolveTarget bool autoMergeTarget bool mergeMethod string prEditField int prEditEditors [prEditFieldCount]textEditor prEditOriginal PullRequestMetadata prEditBranches []RepositoryBranch prEditBranchesLoading bool prEditBranchesError string prEditBranchIndex int prEditUsers []RepositoryUser prEditUsersLoading bool prEditUsersError string prEditUserIndex int prEditGeneration uint64 cursorOutput *terminalCursorOutput foldResolved bool threadListWidthPercent int pathScroll bool pathScrollInterval time.Duration pathScrollStep int threadStatusOrder []string threadWithinStatus string dashboardMode string dashboardReturn screen compactReviews bool viewerLabel string editorMode string keybindings KeyBindings readState *readStateStore drafts *draftStore knownThreads map[string]bool knownComments map[string]bool initializedPRs map[string]bool unreadThreads map[string]bool unreadComments map[string]bool newThreads map[string]bool updatedThreads map[string]bool ai *AIController aiStore *AIStore aiMode aiMode aiMenuIndex int aiInput string aiInputEditor textEditor aiPreview AIPreview aiCancel context.CancelFunc aiStatus AIProviderStatus aiStatusBusy bool aiPreviewScroll int aiProgress AIRunProgress aiSpinner int aiEvents <-chan tea.Msg aiGeneration uint64 difflet diffletModel mutations *mutationQueueStore mutationReplayBusy bool blockedMutation *mutationOperation blockedMutationDetails PRDetails blockedMutationChoice int editingMutationID string } type AppSettings struct { FoldResolved bool ThreadListWidthPercent int PathScroll bool PathScrollInterval time.Duration ThreadStatusOrder []string ThreadWithinStatus string DashboardMode string CompactReviews bool ViewerLabel string EditorMode string KeyBindings KeyBindings ReadState *readStateStore Drafts *draftStore Mutations *mutationQueueStore AI *AIController AIStore *AIStore Mascot bool MascotExpressive bool MascotAnimated bool } func defaultAppSettings() AppSettings { return AppSettings{ FoldResolved: true, ThreadListWidthPercent: 33, PathScrollInterval: 350 * time.Millisecond, ThreadStatusOrder: []string{"unresolved", "outdated", "resolved"}, ThreadWithinStatus: "file", DashboardMode: "hotkey", CompactReviews: true, ViewerLabel: "login", EditorMode: "vim", KeyBindings: defaultKeyBindings(), } } func NewApp(service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration) App { return NewAppWithSettings(service, owner, repo, showAll, limit, poll, defaultAppSettings()) } func NewAppWithSettings( service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration, settings AppSettings, ) App { state := settings.ReadState if state == nil { state = &readStateStore{Data: make(map[string]readPRState)} } difflet := newDifflet(settings.Mascot, settings.MascotExpressive, settings.MascotAnimated) difflet.visible = false return App{ service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll, folded: make(map[string]bool), loading: true, foldResolved: settings.FoldResolved, threadListWidthPercent: settings.ThreadListWidthPercent, pathScroll: settings.PathScroll, pathScrollInterval: settings.PathScrollInterval, threadStatusOrder: append([]string(nil), settings.ThreadStatusOrder...), threadWithinStatus: settings.ThreadWithinStatus, dashboardMode: settings.DashboardMode, dashboardReturn: prScreen, healthReturn: prScreen, requests: &requestCoordinator{}, compactReviews: settings.CompactReviews, viewerLabel: firstNonEmpty(settings.ViewerLabel, "login"), editorMode: settings.EditorMode, keybindings: settings.KeyBindings, readState: state, drafts: settings.Drafts, mutations: settings.Mutations, knownThreads: make(map[string]bool), knownComments: make(map[string]bool), initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool), unreadComments: make(map[string]bool), newThreads: make(map[string]bool), updatedThreads: make(map[string]bool), ai: settings.AI, aiStore: settings.AIStore, difflet: difflet, } } func (m App) Init() tea.Cmd { commands := []tea.Cmd{m.loadPRs(true), m.nextTick()} if m.pathScroll { commands = append(commands, m.nextPathTick()) } if command := m.difflet.start(); command != nil { commands = append(commands, command) } return tea.Batch(commands...) } func (m *App) resetInputEditor(editor *textEditor, text string) { *editor = newTextEditor(text, m.editorMode == "vim") editor.keys = m.keybindings editor.hardwareCursor = m.cursorOutput != nil } func (m *App) resetSearchEditor(text string) { m.searchEditor = newTextEditor(text, false) m.searchEditor.keys = m.keybindings m.searchEditor.hardwareCursor = m.cursorOutput != nil } func (m App) nextTick() tea.Cmd { return tea.Tick(m.adaptivePollInterval(time.Now()), func(t time.Time) tea.Msg { return tickMsg(t) }) } func (m App) adaptivePollInterval(now time.Time) time.Duration { interval := m.poll if provider, ok := m.service.(healthProvider); ok { rate := provider.RateLimit() switch { case now.Before(rate.RetryAfter): interval = max(interval, rate.RetryAfter.Sub(now)) case rate.Limit > 0 && rate.Remaining*20 < rate.Limit: interval *= 8 case rate.Limit > 0 && rate.Remaining*100 < rate.Limit*15: interval *= 4 case rate.Limit > 0 && rate.Remaining*10 < rate.Limit*3: interval *= 2 } } interval = min(interval, 15*time.Minute) // Stable-enough per-call jitter prevents synchronized clients without // introducing a shared random source into the model. jitter := interval / 10 if jitter > 0 { offset := time.Duration(now.UnixNano()%int64(2*jitter+1)) - jitter interval += offset } return max(interval, 2*time.Second) } func (m App) nextPathTick() tea.Cmd { return tea.Tick(m.pathScrollInterval, func(t time.Time) tea.Msg { return pathTickMsg(t) }) } func (m App) loadPRs(useCache bool) tea.Cmd { commands := []tea.Cmd{m.loadLivePRs()} if cache, ok := m.service.(cachedSnapshotService); ok && useCache { commands = append([]tea.Cmd{func() tea.Msg { prs, err := cache.CachedPullRequests(m.owner, m.repo, m.limit, m.showAll) return prsLoadedMsg{prs: prs, err: err, cached: true} }}, commands...) } return tea.Batch(commands...) } func (m App) loadLivePRs() tea.Cmd { return func() tea.Msg { ctx, cancel, requestID := m.requests.start(20 * time.Second) defer cancel() var prs []PullRequest var err error if live, ok := m.service.(liveGitHubService); ok { prs, err = live.LivePullRequests(ctx, m.owner, m.repo, m.limit, m.showAll) } else { prs, err = m.service.ListPullRequests(ctx, m.owner, m.repo, m.limit, m.showAll) } return prsLoadedMsg{prs: prs, err: err, requestID: requestID} } } func (m App) loadDetails(pr PullRequest, useCache bool) tea.Cmd { commands := []tea.Cmd{m.loadLiveDetails(pr)} if cache, ok := m.service.(cachedSnapshotService); ok && useCache { commands = append([]tea.Cmd{func() tea.Msg { details, err := cache.CachedPullRequest(pr.Owner, pr.Repository, pr.Number) return detailsLoadedMsg{ owner: pr.Owner, repo: pr.Repository, number: pr.Number, details: details, err: err, cached: true, } }}, commands...) } return tea.Batch(commands...) } func (m App) loadLiveDetails(pr PullRequest) tea.Cmd { return func() tea.Msg { ctx, cancel, requestID := m.requests.start(60 * time.Second) defer cancel() var details PRDetails var err error if live, ok := m.service.(liveGitHubService); ok { details, err = live.LivePullRequest(ctx, pr.Owner, pr.Repository, pr.Number) } else { details, err = m.service.GetPullRequest(ctx, pr.Owner, pr.Repository, pr.Number) } return detailsLoadedMsg{ owner: pr.Owner, repo: pr.Repository, number: pr.Number, details: details, err: err, requestID: requestID, } } } func (m App) loadDetailsEnrichment(details PRDetails) tea.Cmd { service, ok := m.service.(GitHubEnrichmentService) if !ok { return nil } return func() tea.Msg { ctx, cancel, requestID := m.requests.start(60 * time.Second) defer cancel() return detailsEnrichedMsg{ enrichment: service.EnrichPullRequest(ctx, details), requestID: requestID, } } } 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 } m.writeMode, m.writeThreadID, m.replyDraft, m.err = writeReply, thread.ID, "", nil m.restoreReplyDraft(thread.ID) m.resetInputEditor(&m.replyEditor, m.replyDraft) m.folded[thread.ID] = false m.focusThreadDetail() m.scroll = m.detailMaxScroll() } func (m *App) startResolveToggle() { thread := m.selectedThread() if reason := m.writeActionUnavailable("resolve", thread); reason != "" { m.err = errors.New(reason) return } m.writeMode, m.writeThreadID, m.resolveTarget, m.err = writeResolveConfirm, thread.ID, !thread.IsResolved, nil } func (m *App) startAutoMergeToggle() { target := m.details.AutoMerge == nil if reason := m.mergeActionUnavailable(target); reason != "" { m.err = errors.New(reason) return } m.autoMergeTarget = target m.mergeMethod = preferredMergeMethod(m.details) m.writeMode, m.err = writeAutoMergeConfirm, nil } func (m *App) startMergeNow() { if reason := m.mergeNowUnavailable(); reason != "" { m.err = errors.New(reason) return } m.mergeMethod = preferredMergeMethod(m.details) m.writeMode, m.err = writeMergeNowConfirm, nil } func preferredMergeMethod(pr PRDetails) string { for _, preferred := range []string{"SQUASH", "MERGE", "REBASE"} { for _, allowed := range pr.AllowedMergeMethods { if allowed == preferred { return preferred } } } return "MERGE" } func (m App) mergeActionUnavailable(enabling bool) string { if m.loading { return "auto-merge unavailable while PR data is refreshing" } if m.details.FromCache { return "auto-merge unavailable from an offline cached snapshot" } if _, ok := m.service.(GitHubMergeService); !ok { return "configured GitHub service does not support auto-merge" } if m.details.Merged || m.details.State == "CLOSED" { return "pull request is already closed" } if enabling { if m.details.HeadOID == "" { return "current pull request head commit is unavailable" } if !m.details.Permissions.CanEnableMerge { return "GitHub did not grant permission to enable auto-merge" } if len(m.details.AllowedMergeMethods) == 0 { return "repository does not expose an allowed merge method" } } else if !m.details.Permissions.CanDisableMerge { return "GitHub did not grant permission to disable auto-merge" } return "" } func (m App) mergeNowUnavailable() string { if reason := m.mergeActionUnavailable(true); reason != "" && !strings.Contains(reason, "enable auto-merge") { return strings.ReplaceAll(reason, "auto-merge", "merge") } return mergeNowStateReason(m.details) } func mergeNowStateReason(pr PRDetails) string { if pr.Merged { return "pull request is already merged" } if pr.State == "CLOSED" { return "pull request is closed" } if pr.IsDraft { return "draft pull requests cannot be merged" } if pr.Requirements.RequiresMergeQueue { return "this branch requires the merge queue" } if pr.Mergeable != "MERGEABLE" { if pr.Mergeable == "CONFLICTING" { return "pull request has merge conflicts" } return "GitHub has not determined that the pull request is mergeable" } if pr.Requirements.RequiresApprovals && pr.ReviewDecision != "APPROVED" { return "required approving reviews are not complete" } if pr.Requirements.RequiresStatusChecks && pr.CheckState != "SUCCESS" && pr.CheckState != "EXPECTED" { return "required status checks are not successful" } open, outdated, _ := threadStatusCounts(pr.Threads) if pr.Requirements.RequiresConversation && open+outdated > 0 { return "required review conversations are unresolved" } if len(pr.AllowedMergeMethods) == 0 { return "repository does not expose an allowed merge method" } return "" } 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 && m.mutations == nil { return "write action unavailable while PR data is refreshing" } if m.details.FromCache && m.mutations == nil { return "offline mutation queue is unavailable" } if m.mutations != nil && m.mutations.loadErr != nil { return "mutation queue is unavailable: " + m.mutations.loadErr.Error() } if _, ok := m.service.(GitHubWriteService); !ok { return "configured GitHub service does not support write actions" } switch action { case "reply": if !thread.ViewerCanReply { return "GitHub did not grant reply permission for this thread" } case "resolve": if thread.IsResolved && !thread.ViewerCanUnresolve { return "GitHub did not grant unresolve permission for this thread" } if !thread.IsResolved && !thread.ViewerCanResolve { return "GitHub did not grant resolve permission for this thread" } } return "" } func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { if key.Type != tea.KeyRunes && key.Type != tea.KeySpace && keyMatches(key.String(), m.keybindings.General.Quit) { return m, tea.Quit } k := m.keybindings.canonicalWriteKey(key.String()) switch m.writeMode { case writeQueueBlocked: choices := blockedMutationChoices(*m.blockedMutation) switch { case keyMatches(key.String(), m.keybindings.Navigation.Down): m.blockedMutationChoice = min(m.blockedMutationChoice+1, len(choices)-1) case keyMatches(key.String(), m.keybindings.Navigation.Up): m.blockedMutationChoice = max(0, m.blockedMutationChoice-1) case keyMatches(key.String(), m.keybindings.General.Confirm), keyMatches(key.String(), m.keybindings.Views.Open): return m, m.resolveBlockedMutation(m.blockedMutationChoice) case keyMatches(key.String(), m.keybindings.Input.Cancel), keyMatches(key.String(), m.keybindings.General.Reject): m.writeMode, m.blockedMutation, m.err = writeNone, nil, nil } return m, nil case writeReply: switch k { case "esc": if m.replyEditor.Modal && m.replyEditor.Mode != textEditorNormal { m.replyEditor.handleKeyAtWidth(key, true, m.threadInputWidth()) m.replyDraft = m.replyEditor.Text } else { m.writeMode, m.replyDraft, m.replyEditor, m.writeThreadID = writeNone, "", textEditor{}, "" m.scroll = min(m.scroll, m.detailMaxScroll()) } case "ctrl+s": if strings.TrimSpace(m.replyDraft) == "" { m.err = errors.New("reply cannot be empty") } else { m.writeMode, m.err = writeReplyConfirm, nil } default: if m.replyEditor.handleKeyAtWidth(key, true, m.threadInputWidth()) { m.replyDraft = m.replyEditor.Text m.err = nil } } if m.writeMode == writeReply { m.ensureThreadInputCursorVisible() return m, m.queueReplyDraft() } case writeReplyConfirm: switch k { case "y": m.writeMode = writeReplyBusy return m, tea.Batch(m.submitReply(), m.difflet.setState(diffletLoading)) case "n", "esc": m.writeMode = writeReply m.scroll = m.detailMaxScroll() } case writeResolveConfirm: switch k { case "y": m.writeMode = writeResolveBusy return m, tea.Batch(m.submitResolution(), m.difflet.setState(diffletLoading)) case "n", "esc": m.writeMode, m.writeThreadID = writeNone, "" } case writeAutoMergeConfirm: switch k { case "y": m.writeMode = writeAutoMergeBusy return m, tea.Batch(m.submitAutoMerge(), m.difflet.setState(diffletLoading)) case "n", "esc": m.writeMode = writeNone } case writeMergeNowConfirm: switch k { case "y": m.writeMode = writeMergeNowBusy return m, tea.Batch(m.submitMergeNow(), m.difflet.setState(diffletLoading)) case "n", "esc": m.writeMode = writeNone } case writePREdit, writePREditConfirm: return m.updatePREditInput(key) } return m, nil } func (m App) submitReply() tea.Cmd { if m.mutations != nil { thread := m.threadByID(m.writeThreadID) operation := mutationOperation{ Kind: mutationReply, Owner: m.details.Owner, Repository: m.details.Repository, Number: m.details.Number, PRID: m.details.ID, ThreadID: m.writeThreadID, Body: strings.TrimRight(m.replyDraft, "\n"), Viewer: m.details.ViewerLogin, Permissions: m.details.Permissions, } if thread != nil { operation.ThreadCanReply = thread.ViewerCanReply } return m.enqueueMutation(operation) } writer := m.service.(GitHubWriteService) threadID, body := m.writeThreadID, strings.TrimRight(m.replyDraft, "\n") return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() comment, err := writer.ReplyToThread(ctx, threadID, body) return threadRepliedMsg{threadID: threadID, comment: comment, err: err} } } 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} } } if m.mutations != nil { thread := m.threadByID(m.writeThreadID) operation := mutationOperation{ Kind: mutationResolution, Owner: m.details.Owner, Repository: m.details.Repository, Number: m.details.Number, PRID: m.details.ID, ThreadID: m.writeThreadID, Resolved: m.resolveTarget, Viewer: m.details.ViewerLogin, Permissions: m.details.Permissions, } if thread != nil { operation.ThreadCanResolve = thread.ViewerCanResolve operation.ThreadCanUnresolve = thread.ViewerCanUnresolve } return m.enqueueMutation(operation) } writer := m.service.(GitHubWriteService) threadID, resolved := m.writeThreadID, m.resolveTarget return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() thread, err := writer.SetThreadResolved(ctx, threadID, resolved) return threadResolvedMsg{threadID: threadID, thread: thread, err: err} } } func (m App) submitAutoMerge() tea.Cmd { writer := m.service.(GitHubMergeService) id, head, method, enabled := m.details.ID, m.details.HeadOID, m.mergeMethod, m.autoMergeTarget return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() request, err := writer.SetPullRequestAutoMerge(ctx, id, head, method, enabled) return autoMergeUpdatedMsg{request: request, enabled: enabled, err: err} } } func (m App) submitMergeNow() tea.Cmd { writer := m.service.(GitHubMergeService) id, head, method := m.details.ID, m.details.HeadOID, m.mergeMethod return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() result, err := writer.MergePullRequest(ctx, id, head, method) return pullRequestMergedMsg{result: result, err: err} } } func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if tick, ok := msg.(diffletTickMsg); ok { return m, m.difflet.update(tick) } if mouse, ok := msg.(tea.MouseMsg); ok && m.handleMouseWheel(mouse) { return m, nil } 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 return m, m.difflet.setVisible( m.width >= diffletWidth && m.height >= diffletHeight+4, ) case tickMsg: if !m.loading { m.loading = true diffletCommand := m.difflet.setState(diffletLoading) targetScreen := m.screen if targetScreen == healthScreen { targetScreen = m.healthReturn } if (targetScreen == dashboardScreen || targetScreen == threadScreen) && m.details.Number != 0 { return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.nextTick(), diffletCommand) } return m, tea.Batch(m.loadPRs(false), m.nextTick(), diffletCommand) } return m, m.nextTick() case pathTickMsg: if m.pathScroll && m.screen == threadScreen { m.pathScrollStep++ } if m.pathScroll { return m, m.nextPathTick() } return m, nil case prsLoadedMsg: if m.requests != nil && !m.requests.current(msg.requestID) { return m, nil } if (m.screen != prScreen && !(m.screen == healthScreen && m.healthReturn == prScreen)) || msg.cached && !m.loading { return m, nil } if !msg.cached { m.loading = false } if msg.err != nil { if msg.cached { m.recordHealth("pull request cache", healthWarning, msg.err.Error()) return m, nil } m.err = msg.err command := m.difflet.setState(diffletSad) m.recordHealth("pull request list", healthError, msg.err.Error()) return m, command } selected := "" if len(m.prs) > 0 && m.prIndex < len(m.prs) { selected = m.prs[m.prIndex].ID } m.prs = m.projectQueuedPullRequests(msg.prs) sort.SliceStable(m.prs, func(i, j int) bool { left, right := strings.ToLower(m.prs[i].RepoWithOwner), strings.ToLower(m.prs[j].RepoWithOwner) if left != right { return left < right } return m.prs[i].UpdatedAt.After(m.prs[j].UpdatedAt) }) m.prIndex = indexPR(m.prs, selected) m.err = nil if msg.cached && len(msg.prs) > 0 { m.lastRefresh = msg.prs[0].CachedAt } else { m.lastRefresh = time.Now() } if len(m.prs) == 0 { return m, tea.Batch(m.difflet.setState(diffletSleeping), m.startMutationReplay()) } return m, tea.Batch(m.difflet.setState(diffletIdle), m.startMutationReplay()) case detailsLoadedMsg: if m.requests != nil && !m.requests.current(msg.requestID) { return m, nil } if m.screen != dashboardScreen && m.screen != threadScreen && !(m.screen == healthScreen && (m.healthReturn == dashboardScreen || m.healthReturn == threadScreen)) { return m, nil } if msg.cached && !m.loading { return m, nil } if !msg.cached { m.loading = false } if m.details.Number != 0 && (msg.number != m.details.Number || msg.owner != m.details.Owner || msg.repo != m.details.Repository) { return m, nil } if msg.err != nil { if msg.cached { m.recordHealth("PR cache", healthWarning, msg.err.Error()) return m, nil } m.err = msg.err command := m.difflet.setState(diffletSad) m.recordHealth("PR refresh", healthError, msg.err.Error()) return m, command } selected := "" anchor := "" if m.threadIndex < len(m.details.Threads) { selected = m.details.Threads[m.threadIndex].ID anchor = m.detailScrollAnchor() } 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) msg.details = m.projectQueuedMutations(msg.details) sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus) m.details = msg.details m.err = nil diffletCommand := m.difflet.setState(m.restingDiffletState()) if len(m.updatedThreads) > 0 { diffletCommand = m.difflet.setState(diffletNewComment) } for _, issue := range msg.details.DataIssues { m.recordHealth(issue.Component, healthWarning, issue.Message) } if msg.details.ConflictFileError != "" { m.recordHealth("conflict file scan", healthWarning, msg.details.ConflictFileError) } m.threadIndex = indexThread(m.details.Threads, selected) if selected != "" && (len(m.details.Threads) == 0 || m.details.Threads[m.threadIndex].ID != selected) { m.scroll = 0 } for _, thread := range m.details.Threads { if _, set := m.folded[thread.ID]; !set && thread.IsResolved && m.foldResolved { m.folded[thread.ID] = true } } if m.screen == dashboardScreen { m.scroll = min(m.scroll, m.dashboardMaxScroll()) } else { if anchor != "" { m.restoreDetailAnchor(anchor) } else { m.scroll = min(m.scroll, m.detailMaxScroll()) } } m.err = nil if msg.cached { m.lastRefresh = msg.details.CachedAt } else { m.lastRefresh = time.Now() if _, ok := m.service.(GitHubEnrichmentService); ok { m.secondaryLoading = true diffletCommand = m.difflet.setState(diffletLoading) return m, tea.Batch(m.loadDetailsEnrichment(msg.details), diffletCommand, m.startMutationReplay()) } } if !msg.cached { return m, tea.Batch(diffletCommand, m.startMutationReplay()) } return m, diffletCommand case detailsEnrichedMsg: if m.requests != nil && !m.requests.current(msg.requestID) { return m, nil } enrichment := msg.enrichment if enrichment.Owner != m.details.Owner || enrichment.Repository != m.details.Repository || enrichment.Number != m.details.Number || enrichment.HeadOID != m.details.HeadOID { return m, nil } m.secondaryLoading = false m.details.DataIssues = removeDataIssues( m.details.DataIssues, "check annotations", "conflict file scan", ) for index := range m.details.Checks { if annotations, ok := enrichment.CheckAnnotations[m.details.Checks[index].ID]; ok { m.details.Checks[index].Annotations = annotations } } if enrichment.ConflictFiles != nil { m.details.ConflictFiles = enrichment.ConflictFiles m.details.ConflictFileError = "" } for _, issue := range enrichment.Issues { m.details.DataIssues = append(m.details.DataIssues, issue) if issue.Component == "conflict file scan" { m.details.ConflictFileError = issue.Message } m.recordHealth(issue.Component, healthWarning, issue.Message) } return m, m.difflet.setState(m.restingDiffletState()) case draftFlushMsg: if msg.err != nil { m.recordHealth("draft persistence", healthWarning, msg.err.Error()) } case mutationQueuedMsg: if msg.err != nil { m.err = fmt.Errorf("queue mutation: %w", msg.err) m.recordHealth("mutation queue", healthError, msg.err.Error()) switch msg.operation.Kind { case mutationReply: m.writeMode = writeReply case mutationResolution: m.writeMode = writeNone case mutationPREdit: m.writeMode = writePREdit } return m, m.difflet.setState(diffletRecoverableError) } selected := "" if msg.operation.Kind == mutationResolution { selected = m.selectionAfterResolution( msg.operation.ThreadID, msg.operation.Resolved, ) } m.details = m.projectQueuedMutations(m.details) if msg.operation.Kind == mutationResolution { if thread := m.threadByID(msg.operation.ThreadID); thread != nil { m.folded[thread.ID] = thread.IsResolved && m.foldResolved } if msg.operation.Resolved { m.markThreadRead(msg.operation.ThreadID) } sortReviewThreads(m.details.Threads, m.threadStatusOrder, m.threadWithinStatus) m.threadIndex = indexThread(m.details.Threads, selected) } m.err = nil switch msg.operation.Kind { case mutationReply: _ = m.drafts.delete(replyDraftKey( msg.operation.Owner, msg.operation.Repository, msg.operation.Number, msg.operation.ThreadID, )) m.replyDraft, m.replyEditor, m.writeThreadID = "", textEditor{}, "" case mutationResolution: m.writeThreadID = "" case mutationPREdit: _ = m.drafts.delete(prMetadataDraftKey( msg.operation.Owner, msg.operation.Repository, msg.operation.Number, )) m.clearPREdit() } m.writeMode = writeNone if m.details.FromCache { return m, m.difflet.setState(diffletSuccess) } return m, tea.Batch(m.startMutationReplay(), m.difflet.setState(diffletSuccess)) case mutationReplayMsg: m.mutationReplayBusy = false if msg.state == mutationReplayWaiting { if msg.err != nil { m.recordHealth("mutation replay", healthWarning, msg.err.Error()) } m.details = m.projectQueuedMutations(m.details) m.err = nil return m, m.difflet.setState(diffletSuccess) } if msg.state == mutationReplayVerifying { m.details = m.projectQueuedMutations(m.details) m.err = nil if m.details.Owner == msg.operation.Owner && m.details.Repository == msg.operation.Repository && m.details.Number == msg.operation.Number { m.loading = true return m, tea.Batch( m.loadDetails(m.details.PullRequest, false), m.difflet.setState(diffletSuccess), ) } return m, m.difflet.setState(diffletSuccess) } if msg.state == mutationReplayBlocked { m.details = m.projectQueuedMutations(m.details) m.blockedMutation = &msg.operation m.blockedMutationDetails = msg.details m.blockedMutationChoice = 0 m.writeMode = writeQueueBlocked m.err = errors.New(msg.reason) m.recordHealth("mutation replay", healthWarning, msg.reason) return m, m.difflet.setState(diffletRecoverableError) } if err := m.mutations.remove(msg.operation.ID); err != nil { m.err = fmt.Errorf("finish queued mutation: %w", err) m.recordHealth("mutation queue", healthError, err.Error()) return m, m.difflet.setState(diffletRecoverableError) } m.err = nil if m.details.Owner == msg.operation.Owner && m.details.Repository == msg.operation.Repository && m.details.Number == msg.operation.Number { m.loading = true details := msg.details requestID := uint64(0) if m.requests != nil { requestID = m.requests.supersede() } return m, tea.Batch( func() tea.Msg { return detailsLoadedMsg{ owner: details.Owner, repo: details.Repository, number: details.Number, details: details, requestID: requestID, } }, m.difflet.setState(diffletSuccess), ) } return m, tea.Batch(m.startMutationReplay(), m.difflet.setState(diffletSuccess)) case branchesLoadedMsg: if msg.generation != m.prEditGeneration || m.writeMode != writePREdit || msg.owner != m.details.Owner || msg.repo != m.details.Repository { return m, nil } m.prEditBranchesLoading = false if msg.err != nil { m.prEditBranchesError = msg.err.Error() m.recordHealth("branch recommendations", healthWarning, msg.err.Error()) m.ensurePREditCursorVisible() return m, nil } m.prEditBranches = msg.branches m.prEditBranchesError = "" m.prEditBranchIndex = 0 m.ensurePREditCursorVisible() case repositoryUsersLoadedMsg: if msg.generation != m.prEditGeneration || m.writeMode != writePREdit || msg.owner != m.details.Owner || msg.repo != m.details.Repository { return m, nil } m.prEditUsersLoading = false if msg.err != nil { m.prEditUsersError = msg.err.Error() m.recordHealth("repository user recommendations", healthWarning, msg.err.Error()) m.ensurePREditCursorVisible() return m, nil } m.prEditUsers = msg.users m.prEditUsersError = "" m.prEditUserIndex = 0 m.ensurePREditCursorVisible() case threadResolvedMsg: m.writeMode = writeNone if msg.err != nil { m.err = fmt.Errorf("change thread resolution: %w", msg.err) m.recordHealth("thread resolution", healthError, msg.err.Error()) return m, m.difflet.setState(diffletRecoverableError) } selected := m.selectionAfterResolution(msg.threadID, msg.thread.IsResolved) for index := range m.details.Threads { if m.details.Threads[index].ID != msg.threadID { continue } thread := &m.details.Threads[index] thread.IsResolved = msg.thread.IsResolved thread.IsOutdated = msg.thread.IsOutdated thread.ViewerCanResolve = msg.thread.ViewerCanResolve thread.ViewerCanUnresolve = msg.thread.ViewerCanUnresolve thread.ViewerCanReply = msg.thread.ViewerCanReply m.folded[thread.ID] = thread.IsResolved && m.foldResolved break } if msg.thread.IsResolved { m.markThreadRead(msg.threadID) } sortReviewThreads(m.details.Threads, m.threadStatusOrder, m.threadWithinStatus) m.threadIndex = indexThread(m.details.Threads, selected) m.writeThreadID = "" m.err = nil m.lastRefresh = time.Now() return m, m.difflet.setState(diffletSuccess) case threadRepliedMsg: if msg.err != nil { m.writeMode = writeReply m.err = fmt.Errorf("reply to review thread: %w", msg.err) m.recordHealth("thread reply", healthError, msg.err.Error()) m.scroll = m.detailMaxScroll() return m, m.difflet.setState(diffletRecoverableError) } m.writeMode = writeNone for index := range m.details.Threads { if m.details.Threads[index].ID == msg.threadID { m.details.Threads[index].Comments = append(m.details.Threads[index].Comments, msg.comment) m.threadIndex = index m.markCommentRead(msg.comment.ID) break } } draftKey := replyDraftKey( m.details.Owner, m.details.Repository, m.details.Number, msg.threadID, ) if err := m.drafts.delete(draftKey); err != nil { m.recordHealth("draft persistence", healthWarning, err.Error()) } m.replyDraft, m.replyEditor, m.writeThreadID = "", textEditor{}, "" m.err = nil m.lastRefresh = time.Now() return m, m.difflet.setState(diffletSuccess) case autoMergeUpdatedMsg: m.writeMode = writeNone if msg.err != nil { m.err = fmt.Errorf("change auto-merge: %w", msg.err) m.recordHealth("auto-merge", healthError, msg.err.Error()) return m, m.difflet.setState(diffletRecoverableError) } m.err = nil if msg.enabled { m.details.AutoMerge = msg.request m.details.Permissions.CanEnableMerge = false m.details.Permissions.CanDisableMerge = true } else { m.details.AutoMerge = nil m.details.Permissions.CanEnableMerge = true m.details.Permissions.CanDisableMerge = false } m.lastRefresh = time.Now() return m, m.difflet.setState(diffletSuccess) case pullRequestMergedMsg: m.writeMode = writeNone if msg.err != nil { m.err = fmt.Errorf("merge pull request: %w", msg.err) m.recordHealth("merge pull request", healthError, msg.err.Error()) return m, m.difflet.setState(diffletRecoverableError) } m.err = nil m.details.Merged = msg.result.Merged m.details.MergedAt = msg.result.MergedAt m.details.State = "MERGED" m.details.AutoMerge = nil m.lastRefresh = time.Now() return m, m.difflet.setState(diffletSuccess) case pullRequestUpdatedMsg: if msg.err != nil { m.writeMode = writePREdit if msg.peopleSaved { m.applyPREditPeople(msg.people) m.prEditOriginal.Reviewers = slices.Clone(msg.people.Reviewers) m.prEditOriginal.Assignees = slices.Clone(msg.people.Assignees) } m.err = fmt.Errorf("update pull request: %w", msg.err) m.recordHealth("PR metadata update", healthError, msg.err.Error()) m.scroll = 0 return m, m.difflet.setState(diffletRecoverableError) } m.writeMode = writeNone m.details.Title = msg.metadata.Title m.details.Body = msg.metadata.Body m.details.BaseRef = msg.metadata.BaseRef m.applyPREditPeople(PullRequestPeople{ Reviewers: msg.metadata.Reviewers, Assignees: msg.metadata.Assignees, }) m.details.Mergeable = msg.metadata.Mergeable m.details.MergeState = msg.metadata.MergeState m.details.UpdatedAt = msg.metadata.UpdatedAt m.details.ConflictFiles = nil m.details.ConflictFileError = "" for index := range m.prs { if m.prs[index].ID == m.details.ID { m.prs[index].Title = msg.metadata.Title m.prs[index].UpdatedAt = msg.metadata.UpdatedAt break } } draftKey := prMetadataDraftKey( m.details.Owner, m.details.Repository, m.details.Number, ) if err := m.drafts.delete(draftKey); err != nil { m.recordHealth("draft persistence", healthWarning, err.Error()) } m.clearPREdit() m.err = nil m.lastRefresh = time.Now() m.loading = true return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.difflet.setState(diffletLoading)) } key, ok := msg.(tea.KeyMsg) if !ok { return m, nil } k := key.String() if m.helpVisible { k = m.keybindings.canonicalHelpKey(k) switch k { case "ctrl+c": return m, tea.Quit case "?", "esc", "q": m.helpVisible, m.helpScroll = false, 0 case "j", "down": m.helpScroll = min(m.helpScroll+1, m.helpMaxScroll()) case "k", "up": m.helpScroll = max(0, m.helpScroll-1) case "g": m.helpScroll = 0 case "G": m.helpScroll = m.helpMaxScroll() case "ctrl+d", "pgdown": m.helpScroll = min(m.helpScroll+max(3, m.height/2), m.helpMaxScroll()) case "ctrl+u", "pgup": m.helpScroll = max(0, m.helpScroll-max(3, m.height/2)) } return m, nil } if m.writeMode == writePREdit || m.writeMode == writePREditConfirm { editor := m.prEditEditors[m.prEditField] helpOutsideTextInput := key.Type != tea.KeyRunes && key.Type != tea.KeySpace helpInVimNormalMode := editor.Modal && editor.Mode == textEditorNormal if keyMatches(k, m.keybindings.General.Help) && (helpOutsideTextInput || helpInVimNormalMode) { m.helpVisible, m.helpScroll = true, 0 return m, nil } } if m.writeMode != writeNone { return m.updateWriteInput(key) } if m.searching { if key.Type != tea.KeyRunes && key.Type != tea.KeySpace && keyMatches(k, m.keybindings.General.Quit) { return m, tea.Quit } k = m.keybindings.canonicalSearchKey(k) switch k { case "ctrl+c": return m, tea.Quit case "esc": if m.searchEditor.Modal && m.searchEditor.Mode != textEditorNormal { m.searchEditor.handleKey(key, false) m.searchQuery = m.searchEditor.Text } else { m.searching, m.searchQuery, m.searchEditor = false, "", textEditor{} m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1) m.scroll = 0 } case "enter": m.searching = false case "up": m.moveSearch(-1) case "down", "tab": m.moveSearch(1) case "ctrl+u": if !m.searchEditor.Modal || m.searchEditor.Mode == textEditorInsert { m.resetSearchEditor("") m.searchQuery = "" m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1) m.scroll = 0 } default: before := m.searchEditor.Text if m.searchEditor.handleKey(key, false) { m.searchQuery = m.searchEditor.Text if m.searchQuery != before { m.selectBestSearchMatch() } } } return m, nil } rawKey := k k = m.keybindings.canonicalMainKey(k, m.screen) if k == "ctrl+c" || k == "q" { return m, tea.Quit } if k == "?" { m.helpVisible, m.helpScroll = true, 0 return m, nil } if m.pendingZ { m.pendingZ = false if keyMatches(rawKey, m.keybindings.Threads.FoldToggle) && m.screen == threadScreen && len(m.details.Threads) > 0 { thread := m.details.Threads[m.threadIndex] m.folded[thread.ID] = !m.folded[thread.ID] m.scroll = 0 } return m, nil } if k == "z" && m.screen == threadScreen { m.pendingZ = true return m, nil } switch k { case "A": m.openAIMenu() case "c": if m.screen == threadScreen { m.startReply() } case "R": if m.screen == threadScreen { m.startResolveToggle() } case "e": if m.screen == dashboardScreen { return m, m.startPREdit() } case "a": if m.screen == dashboardScreen { m.startAutoMergeToggle() } case "M": if m.screen == dashboardScreen { m.startMergeNow() } case "F": if m.screen == threadScreen { m.searchQuery, m.searchEditor = "", textEditor{} m.threadIndex = clamp(m.threadIndex, 0, len(m.details.Threads)-1) } case "/": if m.screen == threadScreen { m.searching, m.searchQuery, m.searchOrigin = true, "", m.threadIndex m.resetSearchEditor("") m.focus, m.listHidden, m.scroll = threadListPane, false, 0 } case "r": if m.loading { return m, nil } m.loading = true diffletCommand := m.difflet.setState(diffletLoading) targetScreen := m.screen if targetScreen == healthScreen { targetScreen = m.healthReturn } if targetScreen == dashboardScreen || targetScreen == threadScreen { return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), diffletCommand) } return m, tea.Batch(m.loadPRs(false), diffletCommand) case "j", "down": if m.screen == dashboardScreen { m.scrollDashboard(1) } else if m.screen == healthScreen { m.healthScroll = clamp(m.healthScroll+1, 0, m.healthMaxScroll()) } else if m.screen == threadScreen && m.focus == threadDetailPane { m.scrollDetail(1) } else { m.move(1) } case "k", "up": if m.screen == dashboardScreen { m.scrollDashboard(-1) } else if m.screen == healthScreen { m.healthScroll = clamp(m.healthScroll-1, 0, m.healthMaxScroll()) } else if m.screen == threadScreen && m.focus == threadDetailPane { m.scrollDetail(-1) } else { m.move(-1) } case "g": m.toStart() case "G": m.toEnd() case "tab": if m.screen == threadScreen { m.listHidden = !m.listHidden if m.listHidden { m.focusThreadDetail() } else { m.focus = threadListPane } m.scroll = 0 } case "ctrl+d", "pgdown": m.page(1) case "ctrl+u", "pgup": m.page(-1) case "n": if m.screen == threadScreen { m.moveToUnread(1) } case "N": if m.screen == threadScreen { m.moveToUnread(-1) } case "m": if m.screen == threadScreen { m.markCurrentThreadRead() } case "d": if m.screen == prScreen && len(m.prs) > 0 { return m, m.openSelectedPR(dashboardScreen) } if m.screen == threadScreen { m.dashboardReturn, m.screen, m.scroll = threadScreen, dashboardScreen, 0 return m, nil } case "H": if m.screen != healthScreen { m.healthReturn, m.screen, m.healthScroll = m.screen, healthScreen, 0 } case "l": if m.screen == prScreen && len(m.prs) > 0 { return m, m.openSelectedPR(m.defaultPRTargetScreen()) } if m.screen == dashboardScreen { m.screen, m.scroll, m.focus, m.listHidden = threadScreen, 0, threadListPane, false return m, nil } if m.screen == threadScreen { m.focusThreadDetail() } case "h": if m.screen == threadScreen { m.focus = threadListPane m.listHidden = false } case "enter": if m.screen == prScreen && len(m.prs) > 0 { return m, m.openSelectedPR(m.defaultPRTargetScreen()) } if m.screen == dashboardScreen { m.screen, m.scroll, m.focus, m.listHidden = threadScreen, 0, threadListPane, false return m, nil } if m.screen == threadScreen && len(m.details.Threads) > 0 { thread := m.details.Threads[m.threadIndex] m.folded[thread.ID] = !m.folded[thread.ID] m.scroll = 0 } case "b", "esc": if m.screen == healthScreen { m.screen = m.healthReturn return m, nil } if m.screen == threadScreen { if m.dashboardMode == "intermediate" { m.dashboardReturn, m.screen, m.scroll, m.err = prScreen, dashboardScreen, 0, nil return m, nil } m.screen, m.err, m.loading = prScreen, nil, true return m, tea.Batch(m.loadPRs(false), m.difflet.setState(diffletLoading)) } if m.screen == dashboardScreen { m.screen, m.scroll, m.err = m.dashboardReturn, 0, nil if m.screen == prScreen { m.loading = true m.searching, m.searchQuery = false, "" return m, tea.Batch(m.loadPRs(false), m.difflet.setState(diffletLoading)) } return m, nil } } return m, nil } func preservePartialPRData(fresh, previous PRDetails) PRDetails { if previous.Number == 0 || fresh.Owner != previous.Owner || fresh.Repository != previous.Repository || fresh.Number != previous.Number { return fresh } if fresh.HeadOID != "" && fresh.HeadOID == previous.HeadOID && fresh.BaseOID == previous.BaseOID { if fresh.ConflictFiles == nil { fresh.ConflictFiles = previous.ConflictFiles fresh.ConflictFileError = previous.ConflictFileError } annotations := make(map[string][]CheckAnnotation, len(previous.Checks)) for _, check := range previous.Checks { if len(check.Annotations) > 0 { annotations[check.ID] = check.Annotations } } for index := range fresh.Checks { if len(fresh.Checks[index].Annotations) == 0 { fresh.Checks[index].Annotations = annotations[fresh.Checks[index].ID] } } for _, issue := range previous.DataIssues { if issue.Component == "check annotations" || issue.Component == "conflict file scan" { fresh.DataIssues = append(fresh.DataIssues, issue) } } } for _, issue := range fresh.DataIssues { switch issue.Component { case "review threads": if len(previous.Threads) > len(fresh.Threads) { fresh.Threads = previous.Threads } case "conversation": if len(previous.Conversation) > len(fresh.Conversation) { fresh.Conversation = previous.Conversation } case "submitted reviews": if len(previous.Reviews) > len(fresh.Reviews) { fresh.Reviews = previous.Reviews } case "timeline": if len(previous.Timeline) > len(fresh.Timeline) { fresh.Timeline = previous.Timeline } case "checks": if len(previous.Checks) > len(fresh.Checks) { fresh.Checks = previous.Checks } } } return fresh } func removeDataIssues(issues []DataIssue, components ...string) []DataIssue { removed := make(map[string]bool, len(components)) for _, component := range components { removed[component] = true } filtered := issues[:0] for _, issue := range issues { if !removed[issue.Component] { filtered = append(filtered, issue) } } return filtered } func (m App) defaultPRTargetScreen() screen { if m.dashboardMode == "hotkey" { return threadScreen } return dashboardScreen } func (m *App) openSelectedPR(target screen) tea.Cmd { m.screen, m.dashboardReturn = target, prScreen m.details = PRDetails{PullRequest: m.prs[m.prIndex]} m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil m.searching, m.searchQuery = false, "" m.pathScrollStep = 0 return tea.Batch(m.loadDetails(m.details.PullRequest, true), m.difflet.setState(diffletLoading)) } func (m *App) trackThreadUpdates(details PRDetails) { if m.knownThreads == nil { m.knownThreads = make(map[string]bool) m.knownComments = make(map[string]bool) m.initializedPRs = make(map[string]bool) m.unreadThreads = make(map[string]bool) m.unreadComments = make(map[string]bool) m.newThreads = make(map[string]bool) } m.updatedThreads = make(map[string]bool) prID := details.ID if prID == "" { prID = fmt.Sprintf("%s#%d", details.RepoWithOwner, details.Number) } state := m.readState.Data[prID] if state.Threads == nil { state.Threads = make(map[string]bool) } if state.Comments == nil { state.Comments = make(map[string]bool) } if !state.Initialized { for _, thread := range details.Threads { state.Threads[thread.ID] = true for _, comment := range thread.Comments { state.Comments[comment.ID] = true } } state.Initialized = true m.readState.Data[prID] = state if err := m.readState.save(); err != nil { m.recordHealth("read state", healthWarning, err.Error()) } return } readStateChanged := false for _, thread := range details.Threads { threadIsNew := !state.Threads[thread.ID] updated := threadIsNew viewerAuthoredOnly := len(thread.Comments) > 0 for _, comment := range thread.Comments { viewerAuthored := details.ViewerLogin != "" && strings.EqualFold(comment.Author, details.ViewerLogin) if viewerAuthored { if !state.Comments[comment.ID] { state.Comments[comment.ID] = true readStateChanged = true } } else { viewerAuthoredOnly = false } if !state.Comments[comment.ID] { updated = true m.unreadComments[comment.ID] = true } m.knownComments[comment.ID] = true } if threadIsNew && !viewerAuthoredOnly { m.newThreads[thread.ID] = true } else if threadIsNew { state.Threads[thread.ID] = true readStateChanged = true updated = false } m.knownThreads[thread.ID] = true if updated { m.unreadThreads[thread.ID] = true m.updatedThreads[thread.ID] = true } } if readStateChanged { m.readState.Data[prID] = state if err := m.readState.save(); err != nil { m.recordHealth("read state", healthWarning, err.Error()) } } m.initializedPRs[prID] = true } func (m *App) markCurrentThreadRead() { if m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) { return } m.markThreadRead(m.details.Threads[m.threadIndex].ID) } func (m *App) markThreadRead(threadID string) { thread := m.threadByID(threadID) if thread == nil { return } prID := m.currentPRKey() state := m.readState.Data[prID] changed := m.unreadThreads[thread.ID] || m.newThreads[thread.ID] || !state.Initialized || !state.Threads[thread.ID] for _, comment := range thread.Comments { if m.unreadComments[comment.ID] || !state.Comments[comment.ID] { changed = true break } } if !changed { return } delete(m.unreadThreads, thread.ID) delete(m.newThreads, thread.ID) if state.Threads == nil { state.Threads = make(map[string]bool) } if state.Comments == nil { state.Comments = make(map[string]bool) } state.Initialized = true state.Threads[thread.ID] = true for _, comment := range thread.Comments { state.Comments[comment.ID] = true delete(m.unreadComments, comment.ID) } m.readState.Data[prID] = state if err := m.readState.save(); err != nil { m.recordHealth("read state", healthWarning, err.Error()) } } func (m *App) focusThreadDetail() { m.focus = threadDetailPane m.markCurrentThreadRead() } func (m *App) markCommentRead(commentID string) { if commentID == "" { return } delete(m.unreadComments, commentID) prID := m.currentPRKey() state := m.readState.Data[prID] if state.Comments == nil { state.Comments = make(map[string]bool) } state.Comments[commentID] = true m.readState.Data[prID] = state if err := m.readState.save(); err != nil { m.recordHealth("read state", healthWarning, err.Error()) } } func (m App) currentPRKey() string { if m.details.ID != "" { return m.details.ID } return fmt.Sprintf("%s#%d", m.details.RepoWithOwner, m.details.Number) } func (m *App) scrollToFirstUnread() { if m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) { return } thread := m.details.Threads[m.threadIndex] firstUnread := "" for _, comment := range thread.Comments { if m.unreadComments[comment.ID] { firstUnread = comment.ID break } } if firstUnread == "" { return } width, _ := m.detailPaneSize() commentAnchor := "comment:" + firstUnread + ":header" for index, line := range m.renderedDetailLines(width) { if line.anchor == commentAnchor { m.scroll = min(index, m.detailMaxScroll()) return } } } func (m *App) acknowledgeVisibleUnread() { if m.screen != threadScreen || m.focus != threadDetailPane || m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) { return } thread := m.details.Threads[m.threadIndex] lastUnread := "" for _, comment := range thread.Comments { if m.unreadComments[comment.ID] { lastUnread = comment.ID } } if lastUnread == "" { return } width, _ := m.detailPaneSize() lines := m.renderedDetailLines(width) viewportHeight := m.detailViewportHeight() scroll := min(m.scroll, max(0, len(lines)-viewportHeight)) lastAnchor := "comment:" + lastUnread + ":header" for index, line := range lines { if line.anchor == lastAnchor && index >= scroll && index < scroll+viewportHeight { m.markCurrentThreadRead() return } } } func (m *App) moveToUnread(direction int) { count := len(m.details.Threads) if count == 0 { return } for offset := 1; offset <= count; offset++ { index := (m.threadIndex + direction*offset) % count if index < 0 { index += count } if m.unreadThreads[m.details.Threads[index].ID] { m.threadIndex, m.scroll = index, 0 m.scrollToFirstUnread() anchor := m.detailScrollAnchor() m.focusThreadDetail() m.restoreDetailAnchor(anchor) return } } } func (m *App) move(delta int) { if m.screen == prScreen { m.prIndex = clamp(m.prIndex+delta, 0, len(m.prs)-1) return } if m.searchQuery != "" { matches := m.matchingThreadIndices() if len(matches) == 0 { return } position := slices.Index(matches, m.threadIndex) if position < 0 { position = 0 } m.threadIndex = matches[clamp(position+delta, 0, len(matches)-1)] m.scroll = 0 return } m.threadIndex = clamp(m.threadIndex+delta, 0, len(m.details.Threads)-1) m.scroll = 0 } func (m *App) moveSearch(delta int) { matches := m.matchingThreadIndices() if len(matches) == 0 { return } position := 0 for i, index := range matches { if index == m.threadIndex { position = i break } } position = clamp(position+delta, 0, len(matches)-1) m.threadIndex = matches[position] m.scroll = 0 } func (m *App) selectBestSearchMatch() { matches := m.matchingThreadIndices() if len(matches) > 0 { m.threadIndex = matches[0] m.scroll = 0 } } func (m App) matchingThreadIndices() []int { if m.searchQuery == "" { indices := make([]int, len(m.details.Threads)) for i := range m.details.Threads { indices[i] = i } return indices } type match struct { index int score int } filter := parseThreadFilter(m.searchQuery) matches := make([]match, 0, len(m.details.Threads)) for i, thread := range m.details.Threads { if filter.status != "" && threadStatus(thread) != filter.status { continue } if filter.updated && !m.updatedThreads[thread.ID] { continue } if filter.author != "" && !m.threadHasAuthor(thread, filter.author) { continue } if score, ok := fuzzyPathScore(thread.Path, filter.path); ok { matches = append(matches, match{index: i, score: score}) } } sort.SliceStable(matches, func(i, j int) bool { return matches[i].score > matches[j].score }) indices := make([]int, len(matches)) for i, match := range matches { indices[i] = match.index } return indices } type threadFilter struct { path string status string author string updated bool } func parseThreadFilter(query string) threadFilter { var filter threadFilter var pathTerms []string for _, term := range strings.Fields(query) { key, value, found := strings.Cut(term, ":") if !found { pathTerms = append(pathTerms, term) continue } switch strings.ToLower(key) { case "status": value = strings.ToLower(value) if value == "open" { value = "unresolved" } if value == "unresolved" || value == "outdated" || value == "resolved" { filter.status = value } case "author": filter.author = strings.TrimPrefix(strings.ToLower(value), "@") case "updated", "new": filter.updated = value == "" || value == "1" || strings.EqualFold(value, "true") || strings.EqualFold(value, "yes") default: pathTerms = append(pathTerms, term) } } filter.path = strings.Join(pathTerms, " ") return filter } func (m App) threadHasAuthor(thread ReviewThread, author string) bool { for _, comment := range thread.Comments { if strings.Contains(strings.ToLower(comment.Author), author) || strings.Contains(strings.ToLower(m.displayAuthor(comment.Author)), author) { return true } } return false } func sortReviewThreads(threads []ReviewThread, statusOrder []string, withinStatus string) { ranks := map[string]int{ "unresolved": 0, "outdated": 1, "resolved": 2, } for rank, status := range statusOrder { ranks[status] = rank } if withinStatus == "" { withinStatus = "file" } sort.SliceStable(threads, func(i, j int) bool { left, right := threads[i], threads[j] leftRank, rightRank := ranks[threadStatus(left)], ranks[threadStatus(right)] if leftRank != rightRank { return leftRank < rightRank } if withinStatus == "timestamp" { if less, decided := compareThreadTimestamps(left, right); decided { return less } } leftPath, rightPath := strings.ToLower(left.Path), strings.ToLower(right.Path) if leftPath != rightPath { return leftPath < rightPath } if left.Line != right.Line { return left.Line < right.Line } if withinStatus != "timestamp" { if less, decided := compareThreadTimestamps(left, right); decided { return less } } return false }) } func threadStatus(thread ReviewThread) string { if thread.IsResolved { return "resolved" } if thread.IsOutdated { return "outdated" } return "unresolved" } func threadOpenedAt(thread ReviewThread) time.Time { var opened time.Time for _, comment := range thread.Comments { if comment.CreatedAt.IsZero() || (!opened.IsZero() && !comment.CreatedAt.Before(opened)) { continue } opened = comment.CreatedAt } return opened } func compareThreadTimestamps(left, right ReviewThread) (less, decided bool) { leftTime, rightTime := threadOpenedAt(left), threadOpenedAt(right) switch { case leftTime.IsZero() && rightTime.IsZero(): return false, false case leftTime.IsZero(): return false, true case rightTime.IsZero(): return true, true case !leftTime.Equal(rightTime): return leftTime.Before(rightTime), true default: return false, false } } func (m *App) toStart() { if m.screen == prScreen { m.prIndex = 0 } else if m.screen == healthScreen { m.healthScroll = 0 } else if m.screen == dashboardScreen { m.scroll = 0 } else if m.focus == threadDetailPane { m.scroll = 0 m.acknowledgeVisibleUnread() } else { m.threadIndex, m.scroll = 0, 0 } } func (m *App) toEnd() { if m.screen == prScreen { m.prIndex = max(0, len(m.prs)-1) } else if m.screen == dashboardScreen { m.scroll = m.dashboardMaxScroll() } else if m.screen == healthScreen { m.healthScroll = m.healthMaxScroll() } else if m.focus == threadDetailPane { m.scroll = m.detailMaxScroll() m.acknowledgeVisibleUnread() } else { m.threadIndex, m.scroll = max(0, len(m.details.Threads)-1), 0 } } func (m *App) page(direction int) { if m.screen == dashboardScreen { m.scrollDashboard(direction * max(3, m.dashboardViewportHeight()/2)) return } if m.screen == healthScreen { m.healthScroll = clamp( m.healthScroll+direction*max(3, m.healthViewportHeight()/2), 0, m.healthMaxScroll(), ) return } if m.screen == threadScreen && m.focus == threadDetailPane { m.scrollDetail(direction * max(3, m.detailViewportHeight()/2)) return } m.move(direction * max(3, m.height/2)) } func (m *App) handleMouseWheel(message tea.MouseMsg) bool { if message.Action != tea.MouseActionPress { return false } direction := 0 switch message.Button { case tea.MouseButtonWheelUp: direction = -1 case tea.MouseButtonWheelDown: direction = 1 default: return false } delta := direction * mouseWheelScrollStep if m.helpVisible { m.helpScroll = clamp(m.helpScroll+delta, 0, m.helpMaxScroll()) return true } if m.aiMode == aiConfirm { m.aiPreviewScroll = max(0, m.aiPreviewScroll+delta) return true } if m.aiMode != aiNone && m.aiMode != aiDiscussion { return true } switch m.screen { case dashboardScreen: m.scrollDashboard(delta) case healthScreen: m.healthScroll = clamp(m.healthScroll+delta, 0, m.healthMaxScroll()) case threadScreen: if m.focus == threadDetailPane || m.writeMode == writeReply || m.aiMode == aiDiscussion { m.scrollDetail(delta) } else { m.move(delta) } case prScreen: m.move(delta) } return true } func (m *App) scrollDetail(delta int) { m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll()) m.acknowledgeVisibleUnread() } func (m *App) scrollDashboard(delta int) { maxScroll := m.dashboardMaxScroll() m.scroll = clamp(m.scroll+delta, 0, maxScroll) } func (m App) dashboardViewportHeight() int { return max(1, m.height-2) } func (m App) dashboardMaxScroll() int { return max(0, len(m.dashboardDisplayLines())-m.dashboardViewportHeight()) } func (m App) detailPaneSize() (int, int) { if m.contentTop == 0 && len(m.difflet.frameLines()) == diffletHeight && !m.diffletHiddenForCurrentView() && m.screen != dashboardScreen { m, _ = m.diffletContentModel() } topLines := m.threadTopLineCount() height := max(3, m.height-topLines-1) if m.width < 70 || m.listHidden { return max(3, m.width), height } leftWidth := m.threadListWidth() return max(20, m.width-leftWidth-1), height } func (m App) threadTopLineCount() int { topLines := len(m.threadTopLines()) if m.headerWidth > 0 { topLines++ } return topLines } func (m App) threadListWidth() int { percent := m.threadListWidthPercent if percent == 0 { percent = 33 } return clamp(m.width*percent/100, 30, max(30, m.width-20)) } func (m App) detailViewportHeight() int { _, height := m.detailPaneSize() return max(1, height-2) } func (m App) detailMaxScroll() int { width, _ := m.detailPaneSize() return max(0, len(m.renderedDetailLines(width))-m.detailViewportHeight()) } func (m App) View() string { if m.cursorOutput != nil { m.cursorOutput.SetCursor(false, 0, 0) } if m.width == 0 { return "Loading…" } mascot := m.difflet.frameLines() if len(mascot) != diffletHeight { return m.viewContent() } if m.diffletHiddenForCurrentView() { return m.viewContent() } if m.screen == dashboardScreen { return m.viewDashboardWithLines(m.dashboardLinesWithDifflet(mascot)) } content, hasHeader := m.diffletContentModel() if !hasHeader { return lipgloss.NewStyle().Width(m.width).Height(m.height).Render( strings.Join(mascot, "\n") + "\n\n" + content.viewContent(), ) } rendered := content.viewContent() header, body, ok := splitHeader(rendered) if !ok { return lipgloss.NewStyle().Width(m.width).Height(m.height).Render( strings.Join(mascot, "\n") + "\n\n" + rendered, ) } headerBand := renderHeaderWithDifflet( header, mascot, m.width, content.headerWidth, diffletGap, ) return lipgloss.NewStyle().Width(m.width).Height(m.height).Render( headerBand + "\n\n" + body, ) } func (m App) diffletContentModel() (content App, hasHeader bool) { headerWidth := diffletHeaderWidth(m.width) if headerWidth < 1 { headerWidth = m.width } content = m content.headerWidth = headerWidth headerLineCount, hasHeader := content.diffletHeaderLineCount() if !hasHeader { content.height = max(1, m.height-diffletHeight-1) content.contentTop = diffletHeight + 1 return content, hasHeader } bandHeight := max(diffletHeight, headerLineCount) addedRows := bandHeight - headerLineCount if m.width <= diffletWidth { addedRows = diffletHeight } content.height = max(1, m.height-addedRows) content.contentTop = addedRows return content, hasHeader } func (m App) diffletHiddenForCurrentView() bool { return m.helpVisible || (m.aiMode != aiNone && m.aiMode != aiDiscussion) || (m.writeMode != writeNone && m.writeMode != writeReply) || m.screen == healthScreen } func (m App) diffletHeaderLineCount() (int, bool) { if m.diffletHiddenForCurrentView() { return 0, false } switch m.screen { case prScreen: return len(m.prHeaderLines()), true case dashboardScreen: if m.scroll > 0 { return 0, false } return len(m.dashboardHeaderLines()), true case threadScreen: return len(m.threadTopLines()), true default: return 0, false } } func splitHeader(view string) (header []string, body string, ok bool) { lines := strings.Split(view, "\n") for index, line := range lines { if strings.TrimSpace(ansi.Strip(line)) == "" { if index == 0 { return nil, view, false } return lines[:index], strings.Join(lines[index+1:], "\n"), true } } return nil, view, false } func renderHeaderWithDifflet( header, mascot []string, width, headerWidth, gap int, ) string { if width <= diffletWidth { lines := append([]string(nil), mascot...) lines = append(lines, header...) return strings.Join(lines, "\n") } height := max(len(header), len(mascot)) headerText := strings.Join(header, "\n") left := lipgloss.NewStyle().Width(headerWidth).Height(height).Render(headerText) right := lipgloss.NewStyle().Width(diffletWidth).Height(height).Render(strings.Join(mascot, "\n")) return lipgloss.JoinHorizontal( lipgloss.Top, left, strings.Repeat(" ", gap), right, ) } func (m App) wrapHeaderLines(lines []string) []string { if m.headerWidth < 1 { return lines } var wrapped []string for _, line := range lines { value := ansi.Hardwrap(ansi.Wordwrap(line, m.headerWidth, ""), m.headerWidth, false) wrapped = append(wrapped, strings.Split(value, "\n")...) } return wrapped } func (m App) viewContent() 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() } return m.viewWritePopup() } if m.screen == prScreen { return m.viewPRs() } if m.screen == dashboardScreen { return m.viewDashboard() } if m.screen == healthScreen { return m.viewHealth() } return m.viewThreads() } func (m App) viewWritePopup() string { width := max(20, min(76, m.width-4)) thread := m.threadByID(m.writeThreadID) location := "selected thread" if thread != nil { location = fmt.Sprintf("%s:%d", thread.Path, thread.Line) } var lines []string switch m.writeMode { case writeReply: lines = []string{ titleStyle.Render("Reply to " + location), "", } lines = append(lines, renderTextInput( m.replyEditor, width-2, m.cursorOutput != nil, )...) if m.err != nil { lines = append(lines, "", badStyle.Render(m.err.Error())) } if m.editorMode != "vim" { lines = append(lines, "", dimStyle.Render(fmt.Sprintf( "%s newline • %s review • %s %s", primaryKeyLabel(m.keybindings.Input.Newline), primaryKeyLabel(m.keybindings.Input.Submit), primaryKeyLabel(m.keybindings.Input.Cancel), m.inputCancelAction(), ))) } case writeReplyConfirm: lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""} lines = append(lines, renderCommentMarkdown(m.replyDraft, width-2)...) lines = append(lines, "", warnStyle.Render(fmt.Sprintf( "%s submit • %s continue editing", primaryKeyLabel(m.keybindings.General.Confirm), primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel), ))) case writeReplyBusy: lines = []string{titleStyle.Render("Submitting reply…"), "", dimStyle.Render(location)} case writeResolveConfirm: action := "resolve" if !m.resolveTarget { action = "unresolve" } lines = []string{ titleStyle.Render(strings.ToUpper(action[:1]) + action[1:] + " " + location + "?"), "", warnStyle.Render(fmt.Sprintf( "%s confirm • %s cancel", primaryKeyLabel(m.keybindings.General.Confirm), primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel), )), } case writeResolveBusy: action := "Resolving" if !m.resolveTarget { action = "Unresolving" } lines = []string{titleStyle.Render(action + " thread…"), "", dimStyle.Render(location)} case writeAutoMergeConfirm: action := "Enable" detail := " using " + strings.ToLower(m.mergeMethod) if !m.autoMergeTarget { action, detail = "Disable", "" } lines = []string{ titleStyle.Render(fmt.Sprintf("%s auto-merge for %s #%d%s?", action, m.details.RepoWithOwner, m.details.Number, detail)), "", warnStyle.Render(fmt.Sprintf( "%s confirm • %s cancel", primaryKeyLabel(m.keybindings.General.Confirm), primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel), )), } case writeAutoMergeBusy: action := "Enabling" if !m.autoMergeTarget { action = "Disabling" } lines = []string{titleStyle.Render(action + " auto-merge…")} case writeMergeNowConfirm: lines = []string{ titleStyle.Render(fmt.Sprintf( "Merge %s #%d into %s now using %s?", m.details.RepoWithOwner, m.details.Number, m.details.BaseRef, strings.ToLower(m.mergeMethod), )), "", badStyle.Render("This action cannot be undone from diple."), "", warnStyle.Render(fmt.Sprintf( "%s merge now • %s cancel", primaryKeyLabel(m.keybindings.General.Confirm), primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel), )), } case writeMergeNowBusy: lines = []string{titleStyle.Render("Merging pull request…")} case writePREditConfirm: lines = m.prEditConfirmationLines(width - 2) case writePREditBusy: lines = []string{titleStyle.Render("Updating pull request…")} case writeQueueBlocked: operation := *m.blockedMutation lines = []string{ titleStyle.Render("Queued mutation needs attention"), "", warnStyle.Render(operation.LastError), "", } for index, choice := range blockedMutationChoices(operation) { prefix := " " if index == m.blockedMutationChoice { prefix = "› " choice = activeStyle.Render(choice) } lines = append(lines, prefix+choice) } lines = append(lines, "", dimStyle.Render(fmt.Sprintf( "%s/%s select • %s apply • %s dismiss", primaryKeyLabel(m.keybindings.Navigation.Down), primaryKeyLabel(m.keybindings.Navigation.Up), primaryCombinedKeyLabel(m.keybindings.General.Confirm, m.keybindings.Views.Open), primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel), ))) } maxLines := max(3, m.height-4) if len(lines) > maxLines { if m.writeMode == writePREditConfirm { start := clamp(m.helpScroll, 0, len(lines)-maxLines) lines = lines[start : start+maxLines] } else { lines = lines[len(lines)-maxLines:] } } for index := range lines { lines[index] = ansi.Truncate(lines[index], width, "") } popup := paneStyle(true).Width(width).Render(strings.Join(lines, "\n")) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup) } func (m App) threadByID(id string) *ReviewThread { for index := range m.details.Threads { if m.details.Threads[index].ID == id { thread := m.details.Threads[index] return &thread } } return nil } func (m App) selectionAfterResolution(threadID string, resolved bool) string { if m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) { return "" } selected := m.details.Threads[m.threadIndex].ID if !resolved || selected != threadID { return selected } if m.threadIndex+1 < len(m.details.Threads) { return m.details.Threads[m.threadIndex+1].ID } if m.threadIndex > 0 { return m.details.Threads[m.threadIndex-1].ID } return selected } type helpBinding struct { key string action string } func (m App) helpBindings() []helpBinding { if m.writeMode == writePREdit || m.writeMode == writePREditConfirm { bindings := []helpBinding{ {combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.PreviousField), "Move to the next / previous field"}, {keyLabel(m.keybindings.Input.Submit), "Review pull request metadata changes"}, {keyLabel(m.keybindings.Input.Cancel), "Return to Normal mode or cancel the editor"}, {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Move through the description by half a page"}, {combinedKeyLabel(m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next branch or user completion"}, {keyLabel(m.keybindings.Input.Newline), "Complete the selected branch or user"}, } if m.editorMode == "vim" { bindings = append(bindings, helpBinding{combinedKeyLabel( m.keybindings.Navigation.Left, m.keybindings.Navigation.Down, m.keybindings.Navigation.Up, m.keybindings.Navigation.Right, ), "Move left / down / up / right in Normal or Visual mode"}, helpBinding{combinedKeyLabel( m.keybindings.Vim.WordForward, m.keybindings.Vim.WORDForward, m.keybindings.Vim.WordBackward, m.keybindings.Vim.WORDBackward, m.keybindings.Vim.WordEnd, m.keybindings.Vim.WORDEnd, ), "Move by words or WORDs"}, helpBinding{combinedKeyLabel( m.keybindings.Vim.LineStart, m.keybindings.Vim.FirstNonBlank, m.keybindings.Vim.LineEnd, ), "Move to the line start, first non-blank, or line end"}, helpBinding{combinedKeyLabel( m.keybindings.Vim.GoPrefix, m.keybindings.Navigation.Last, ), "Move to the start / end of the active field"}, helpBinding{combinedKeyLabel( m.keybindings.Vim.Insert, m.keybindings.Vim.Append, m.keybindings.Vim.InsertLineStart, m.keybindings.Vim.AppendLineEnd, m.keybindings.Vim.ReplaceCharacter, ), "Enter Insert mode"}, helpBinding{combinedKeyLabel( m.keybindings.Vim.OpenBelow, m.keybindings.Vim.OpenAbove, ), "Open a description line below / above"}, helpBinding{combinedKeyLabel( m.keybindings.Vim.Visual, m.keybindings.Vim.VisualLine, ), "Start character-wise / line-wise Visual mode"}, helpBinding{keyLabel(m.keybindings.Vim.SelectionOtherEnd), "Move to the other end of a Visual selection"}, helpBinding{keyLabel(m.keybindings.Vim.Yank), "Copy the Visual selection to the system clipboard"}, helpBinding{keyLabel(m.keybindings.Vim.Paste), "Paste from the system clipboard"}, helpBinding{combinedKeyLabel( m.keybindings.Vim.Delete, m.keybindings.Vim.DeleteBefore, ), "Delete the selection or text at / before the cursor"}, helpBinding{combinedKeyLabel( m.keybindings.Vim.FindForward, m.keybindings.Vim.FindBackward, m.keybindings.Vim.TillForward, m.keybindings.Vim.TillBackward, ), "Find or move until a character on the current visual line"}, helpBinding{combinedKeyLabel( m.keybindings.Vim.RepeatFind, m.keybindings.Vim.RepeatFindReverse, ), "Repeat the last character find forward / backward"}, ) } else { bindings = append(bindings, helpBinding{combinedKeyLabel( m.keybindings.Navigation.Left, m.keybindings.Navigation.Down, m.keybindings.Navigation.Up, m.keybindings.Navigation.Right, ), "Move the active field cursor"}, helpBinding{combinedKeyLabel( m.keybindings.Input.LineStart, m.keybindings.Input.LineEnd, ), "Move to the start / end of the current visual line"}, helpBinding{keyLabel(m.keybindings.Input.Newline), "Insert a newline"}, helpBinding{combinedKeyLabel( m.keybindings.Input.DeleteBackward, m.keybindings.Input.DeleteForward, ), "Delete text before / at the cursor"}, ) } bindings = append(bindings, helpBinding{keyLabel(m.keybindings.General.Help), "Close this help"}, helpBinding{keyLabel(m.keybindings.General.Quit), "Quit"}, ) return bindings } if m.screen == prScreen { openAction := "Open pull request dashboard" if m.dashboardMode == "hotkey" { openAction = "Open review threads" } return []helpBinding{ {keyLabel(m.keybindings.Navigation.Down), "Next pull request"}, {keyLabel(m.keybindings.Navigation.Up), "Previous pull request"}, {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "First / last pull request"}, {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"}, {keyLabel(m.keybindings.Views.Open), openAction}, {keyLabel(m.keybindings.Views.Dashboard), "Open pull request dashboard"}, {keyLabel(m.keybindings.General.Refresh), "Refresh now"}, {keyLabel(m.keybindings.General.Help), "Close this help"}, {keyLabel(m.keybindings.General.Quit), "Quit"}, } } if m.screen == healthScreen { return []helpBinding{ {keyLabel(m.keybindings.Navigation.Down), "Scroll health details down"}, {keyLabel(m.keybindings.Navigation.Up), "Scroll health details up"}, {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"}, {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"}, {keyLabel(m.keybindings.General.Refresh), "Refresh application data"}, {keyLabel(m.keybindings.General.Back), "Return to the previous screen"}, {keyLabel(m.keybindings.General.Help), "Close this help"}, {keyLabel(m.keybindings.General.Quit), "Quit"}, } } if m.screen == dashboardScreen { backAction := "Return to pull requests" if m.dashboardReturn == threadScreen { backAction = "Return to review threads" } return []helpBinding{ {keyLabel(m.keybindings.Navigation.Down), "Scroll description down"}, {keyLabel(m.keybindings.Navigation.Up), "Scroll description up"}, {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"}, {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"}, {keyLabel(m.keybindings.Views.Edit), "Edit title, branch, reviewers, assignees, 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"}, {keyLabel(m.keybindings.General.Help), "Close this help"}, {keyLabel(m.keybindings.General.Quit), "Quit"}, } } backAction := "Return to pull requests" if m.dashboardMode == "intermediate" { backAction = "Return to PR dashboard" } bindings := []helpBinding{ {combinedKeyLabel(m.keybindings.Navigation.Left, m.keybindings.Navigation.Right), "Focus thread list / detail"}, {combinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up), "Move or scroll focused pane"}, {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "First / last item"}, {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"}, {keyLabel(m.keybindings.Views.ToggleList), "Hide / reveal thread list"}, {keyLabel(m.keybindings.Threads.Search), "Fuzzy-search file paths and combine status:, author:, and updated:true filters"}, {keyLabel(m.keybindings.Threads.ClearFilter), "Clear the active thread filter and show every thread"}, {combinedKeyLabel(m.keybindings.Threads.NextUnread, m.keybindings.Threads.PreviousUnread), "Next / previous new update"}, {keyLabel(m.keybindings.Threads.MarkRead), "Mark the selected thread read"}, {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, []string{sequenceKeyLabel(m.keybindings.Threads.FoldPrefix, m.keybindings.Threads.FoldToggle)}, ), "Fold / expand thread"}, {keyLabel(m.keybindings.General.Back), backAction}, {keyLabel(m.keybindings.General.Refresh), "Refresh now"}, } bindings = append(bindings, helpBinding{keyLabel(m.keybindings.General.Help), "Close this help"}, helpBinding{keyLabel(m.keybindings.General.Quit), "Quit"}, ) return bindings } func (m App) helpVisibleRows() int { // Reserve rows for the popup border, title, title divider, and footer. return max(1, m.height-6) } func (m App) helpMaxScroll() int { return max(0, len(m.helpRows(m.helpContentWidth()))-m.helpVisibleRows()) } func (m App) viewHelp() string { contentWidth := m.helpContentWidth() rows := m.helpRows(contentWidth) visibleRows := m.helpVisibleRows() start := clamp(m.helpScroll, 0, max(0, len(rows)-visibleRows)) end := min(len(rows), start+visibleRows) title := "Pull request picker keys" if m.writeMode == writePREdit || m.writeMode == writePREditConfirm { title = "Pull request editor keys" } else if m.screen == healthScreen { title = "Application health keys" } else if m.screen == dashboardScreen { title = "Pull request dashboard keys" } else if m.screen == threadScreen { title = "Review thread keys" } contentLines := append([]string(nil), rows[start:end]...) if len(rows) > visibleRows { contentLines = append(contentLines, dimStyle.Render(fmt.Sprintf( "%d–%d of %d • %s/%s scroll • %s close", start+1, end, len(rows), primaryKeyLabel(m.keybindings.Navigation.Down), primaryKeyLabel(m.keybindings.Navigation.Up), primaryKeyLabel(m.keybindings.General.Help)+"/"+ primaryKeyLabel(m.keybindings.General.Back), ))) } else { contentLines = append(contentLines, dimStyle.Render( primaryKeyLabel(m.keybindings.General.Help)+"/"+ primaryKeyLabel(m.keybindings.General.Back)+" close", )) } for i := range contentLines { contentLines[i] = ansi.Truncate(contentLines[i], contentWidth, "") } border := lipgloss.NewStyle().Foreground(paneActiveColor) boxLines := []string{ border.Render("╭" + strings.Repeat("─", contentWidth) + "╮"), border.Render("│") + pad(titleStyle.Render(title), contentWidth) + border.Render("│"), border.Render("├" + strings.Repeat("─", contentWidth) + "┤"), } for _, line := range contentLines { boxLines = append(boxLines, border.Render("│")+pad(line, contentWidth)+border.Render("│"), ) } boxLines = append(boxLines, border.Render("╰"+strings.Repeat("─", contentWidth)+"╯")) popup := strings.Join(boxLines, "\n") return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup) } func (m App) helpContentWidth() int { return max(1, min(70, m.width-4)) } func (m App) helpRows(contentWidth int) []string { keyWidth := min(17, max(8, contentWidth/3)) actionWidth := max(1, contentWidth-keyWidth-1) var rows []string bindings := m.helpBindings() for bindingIndex, binding := range bindings { wrappedKeys := ansi.Hardwrap( ansi.Wordwrap(binding.key, keyWidth, ""), keyWidth, false, ) keyLines := strings.Split(wrappedKeys, "\n") wrappedAction := ansi.Hardwrap( ansi.Wordwrap(binding.action, actionWidth, ""), actionWidth, false, ) actionLines := strings.Split(wrappedAction, "\n") lineCount := max(len(keyLines), len(actionLines)) for lineIndex := range lineCount { key, action := "", "" if lineIndex < len(keyLines) { key = keyLines[lineIndex] } if lineIndex < len(actionLines) { action = actionLines[lineIndex] } rows = append(rows, titleStyle.Render(pad(key, keyWidth))+" "+action) } if bindingIndex < len(bindings)-1 { rows = append(rows, dimStyle.Render(strings.Repeat("─", contentWidth))) } } return rows } var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F")) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261")) okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587")) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B")) badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75")) editorLineStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#D7DAE8")). Background(lipgloss.Color("#2C3045")) paneInactiveColor = lipgloss.Color("#50566F") paneActiveColor = lipgloss.Color("#F0B72F") selectedLineBackground = "\x1b[48;5;24m" suggestionRemoveBackground = "\x1b[48;5;52m" suggestionAddBackground = "\x1b[48;5;22m" changedRemoveBackground = "\x1b[48;2;55;0;0m" changedAddBackground = "\x1b[48;2;0;55;0m" ) func paneStyle(active bool) lipgloss.Style { color := paneInactiveColor if active { color = paneActiveColor } return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(color) } func (m App) viewPRs() string { lines := append(m.prHeaderLines(), "") if m.loading && len(m.prs) == 0 { lines = append(lines, "Loading open pull requests…") } else if len(m.prs) == 0 && m.err == nil { lines = append(lines, "No matching assigned pull requests.") } rows, selectedRow := groupedPRRows(m.prs, m.prIndex) available := max(1, m.height-4) start := windowStart(selectedRow, len(rows), available) for _, row := range rows[start:min(len(rows), start+available)] { if row.header { lines = append(lines, titleStyle.Render(row.repository)) continue } pr := m.prs[row.prIndex] draft := "" if pr.IsDraft { draft = " DRAFT" } if pr.FromCache { draft += " CACHED" } if pr.Pending { draft += " PENDING" } titleWidth := max(10, m.width-36) line := fmt.Sprintf(" #%-5d %-*s %2d threads%s", pr.Number, titleWidth, truncate(pr.Title, titleWidth), pr.ReviewCount, draft) if row.prIndex == m.prIndex { line = activeStyle.Render(line) } lines = append(lines, line) } footer := fmt.Sprintf( "%s keys • %s move • %s dashboard • %s quit", primaryKeyLabel(m.keybindings.General.Help), primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up), primaryKeyLabel(m.keybindings.Views.Open), primaryKeyLabel(m.keybindings.General.Quit), ) if m.dashboardMode == "hotkey" { footer = fmt.Sprintf( "%s keys • %s move • %s threads • %s dashboard • %s quit", primaryKeyLabel(m.keybindings.General.Help), primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up), primaryKeyLabel(m.keybindings.Views.Open), primaryKeyLabel(m.keybindings.Views.Dashboard), primaryKeyLabel(m.keybindings.General.Quit), ) } return m.frame(lines, footer) } func (m App) prHeaderLines() []string { header := titleStyle.Render("diple") if m.owner != "" { header += " " + m.owner + "/" + m.repo } if m.showAll { header += dimStyle.Render(" all open pull requests") } else { header += dimStyle.Render(" assigned to you") } return m.wrapHeaderLines([]string{header}) } type prListRow struct { repository string prIndex int header bool } func groupedPRRows(prs []PullRequest, selected int) ([]prListRow, int) { rows := make([]prListRow, 0, len(prs)*2) selectedRow := 0 lastRepository := "" for index, pr := range prs { repository := pr.RepoWithOwner if repository == "" { repository = "(unknown repository)" } if repository != lastRepository { rows = append(rows, prListRow{repository: repository, header: true}) lastRepository = repository } if index == selected { selectedRow = len(rows) } rows = append(rows, prListRow{repository: repository, prIndex: index}) } return rows, selectedRow } func (m App) viewDashboard() string { return m.viewDashboardWithLines(m.dashboardLines()) } func (m App) viewDashboardWithLines(lines []string) string { viewportHeight := m.dashboardViewportHeight() maxScroll := max(0, len(lines)-viewportHeight) scroll := min(m.scroll, maxScroll) visible := lines[scroll:min(len(lines), scroll+viewportHeight)] footer := fmt.Sprintf( "%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), ) if m.writeMode == writePREdit { footer = fmt.Sprintf( "%s keys • %s fields • %s review • %s normal/cancel", primaryKeyLabel(m.keybindings.General.Help), primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.PreviousField), primaryKeyLabel(m.keybindings.Input.Submit), primaryKeyLabel(m.keybindings.Input.Cancel), ) m.positionPREditHardwareCursor(scroll, viewportHeight) } view := m.frame(visible, footer) if m.cursorOutput != nil { view += m.cursorOutput.FrameMarker() } return view } func (m App) activeInputEditor() (textEditor, string, bool) { if m.helpVisible { return textEditor{}, "", false } switch { case m.writeMode == writeReply: return m.replyEditor, "REPLY", m.replyEditor.Modal case m.aiMode == aiDiscussion: return m.aiInputEditor, "AI DISCUSSION", m.aiInputEditor.Modal case m.writeMode == writePREdit: contexts := [...]string{ "EDIT TITLE", "EDIT TARGET BRANCH", "EDIT REVIEWERS", "EDIT ASSIGNEES", "EDIT DESCRIPTION", } editor := m.prEditEditors[m.prEditField] return editor, contexts[m.prEditField], editor.Modal default: return textEditor{}, "", false } } func (m App) inputCancelAction() string { if m.editorMode == "vim" { return "normal/cancel" } return "cancel" } func (m App) dashboardDisplayLines() []string { mascot := m.difflet.frameLines() if len(mascot) == diffletHeight && !m.diffletHiddenForCurrentView() { return m.dashboardLinesWithDifflet(mascot) } return m.dashboardLines() } func (m App) dashboardLinesWithDifflet(mascot []string) []string { lines := m.dashboardLines() headerLines := len(m.dashboardHeaderLines()) metadataStart := headerLines + 1 if headerLines >= len(lines) || strings.TrimSpace(ansi.Strip(lines[headerLines])) != "" { return lines } if len(lines) < metadataStart+diffletHeight { result := append([]string(nil), lines[:headerLines]...) result = append(result, centeredDiffletLines(mascot, m.width)...) return append(result, lines[metadataStart:]...) } band := renderDashboardMetadataWithDifflet( lines[metadataStart:metadataStart+diffletHeight], mascot, m.width, ) result := append([]string(nil), lines[:headerLines]...) result = append(result, band...) return append(result, lines[metadataStart+diffletHeight:]...) } func renderDashboardMetadataWithDifflet(metadata, mascot []string, width int) []string { mascotLeft := max(0, (width-diffletWidth)/2) if width <= diffletWidth || mascotLeft < diffletGap { centered := centeredDiffletLines(mascot, width) return append(centered, metadata...) } metadataWidth := max(1, mascotLeft-diffletGap) height := max(len(metadata), len(mascot)) rendered := make([]string, 0, height) for row := range height { left, right := "", "" if row < len(metadata) { left = ansi.Truncate(metadata[row], metadataWidth, "…") } if row < len(mascot) { right = mascot[row] } rendered = append(rendered, pad(left, mascotLeft)+right) } return rendered } func centeredDiffletLines(mascot []string, width int) []string { centered := make([]string, 0, len(mascot)) for _, line := range mascot { centered = append(centered, lipgloss.PlaceHorizontal( width, lipgloss.Center, line, )) } return centered } func (m App) viewHealth() string { lines := m.healthLines() viewportHeight := m.healthViewportHeight() start := clamp(m.healthScroll, 0, max(0, len(lines)-viewportHeight)) end := min(len(lines), start+viewportHeight) footer := fmt.Sprintf( "%s keys • %s scroll • %s refresh • %s close", primaryKeyLabel(m.keybindings.General.Help), primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up), primaryKeyLabel(m.keybindings.General.Refresh), primaryKeyLabel(m.keybindings.General.Back), ) contentWidth := m.healthContentWidth() border := lipgloss.NewStyle().Foreground(paneActiveColor) boxLines := []string{ border.Render("╭" + strings.Repeat("─", contentWidth) + "╮"), border.Render("│") + pad(titleStyle.Render("Application health"), contentWidth) + border.Render("│"), border.Render("├" + strings.Repeat("─", contentWidth) + "┤"), } for _, line := range lines[start:end] { boxLines = append(boxLines, border.Render("│")+pad(ansi.Truncate(line, contentWidth, ""), contentWidth)+border.Render("│"), ) } for len(boxLines) < viewportHeight+3 { boxLines = append(boxLines, border.Render("│")+strings.Repeat(" ", contentWidth)+border.Render("│"), ) } boxLines = append(boxLines, border.Render("├"+strings.Repeat("─", contentWidth)+"┤"), border.Render("│")+pad(dimStyle.Render(ansi.Truncate(footer, contentWidth, "")), contentWidth)+border.Render("│"), border.Render("╰"+strings.Repeat("─", contentWidth)+"╯"), ) popup := strings.Join(boxLines, "\n") return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup) } func (m App) healthMaxScroll() int { return max(0, len(m.healthLines())-m.healthViewportHeight()) } func (m App) healthContentWidth() int { return max(18, min(96, m.width-4)) } func (m App) healthViewportHeight() int { // Border, title, divider, footer divider, footer, and closing border. return max(1, min(28, m.height-6)) } func (m App) healthLines() []string { width := m.healthContentWidth() lines := []string{} components := []HealthComponent{{ Name: "application", Level: healthOK, Summary: "interactive loop is running", UpdatedAt: time.Now(), }} refresh := HealthComponent{ Name: "refresh", Level: healthOK, Summary: "idle", UpdatedAt: m.lastRefresh, } if m.loading { refresh.Level = healthInfo refresh.Summary = "core refresh in progress" } else if m.secondaryLoading { refresh.Level = healthInfo refresh.Summary = "core data ready; secondary enrichment in progress" } components = append(components, refresh) components = append(components, HealthComponent{ Name: "configuration", Level: healthOK, Summary: "configuration loaded and validated", Detail: "theme " + currentThemeName, }) if m.readState != nil { component := HealthComponent{ Name: "read state", Level: healthOK, Summary: "persistent unread state is available", Detail: m.readState.path, } if m.readState.loadErr != nil { component.Level = healthWarning component.Summary = "state recovery started with an empty store" component.Detail = m.readState.loadErr.Error() } components = append(components, component) } if m.drafts != nil { component := HealthComponent{ Name: "draft persistence", Level: healthOK, Summary: "draft recovery is available", Detail: m.drafts.path, } if m.drafts.loadErr != nil { component.Level = healthWarning component.Summary = "draft recovery file could not be loaded" component.Detail = m.drafts.loadErr.Error() } components = append(components, component) } if m.mutations != nil { count := m.mutations.count() component := HealthComponent{ Name: "mutation queue", Level: healthOK, Summary: fmt.Sprintf("%d queued mutation(s)", count), Detail: m.mutations.path, } if m.mutations.loadErr != nil { component.Level = healthError component.Summary = "mutation queue could not be loaded safely" component.Detail = m.mutations.loadErr.Error() } else if operation, ok := m.mutations.front(); ok && operation.Blocked { component.Level = healthWarning component.Summary = "queued replay is paused" component.Detail = operation.LastError } else if count > 0 { component.Level = healthInfo component.Summary = fmt.Sprintf("%d mutation(s) waiting for ordered replay", count) } 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, Summary: "pull request and review data loaded", UpdatedAt: m.details.UpdatedAt, } if len(m.details.DataIssues) > 0 { core.Level = healthWarning core.Summary = fmt.Sprintf("%d data subsection(s) are partial", len(m.details.DataIssues)) } components = append(components, core) enrichment := HealthComponent{ Name: "PR enrichment", Level: healthOK, Summary: "annotations and conflict analysis loaded", } if m.secondaryLoading { enrichment.Level = healthUnknown enrichment.Summary = "annotations and conflict analysis are still loading" } if m.details.ConflictFileError != "" { enrichment.Level = healthWarning enrichment.Summary = "conflict-file analysis is partial" enrichment.Detail = m.details.ConflictFileError } components = append(components, enrichment) } if m.err != nil { components[0].Level = healthError components[0].Summary = m.err.Error() } if provider, ok := m.service.(healthProvider); ok { components = append(components, provider.HealthReport()...) rate := provider.RateLimit() if !rate.UpdatedAt.IsZero() { level := healthOK if rate.Remaining == 0 || time.Now().Before(rate.RetryAfter) { level = healthError } else if rate.Limit > 0 && rate.Remaining*10 < rate.Limit { level = healthWarning } summary := fmt.Sprintf("%d/%d points remaining", rate.Remaining, rate.Limit) detail := "" if !rate.ResetAt.IsZero() { detail = "resets " + rate.ResetAt.Local().Format("15:04:05") } if time.Now().Before(rate.RetryAfter) { detail = "retry after " + rate.RetryAfter.Local().Format("15:04:05") } components = append(components, HealthComponent{ Name: "rate limit", Level: level, Summary: summary, Detail: detail, UpdatedAt: rate.UpdatedAt, }) } } sort.SliceStable(components, func(i, j int) bool { return components[i].Name < components[j].Name }) for _, component := range components { style := okStyle if component.Level == healthWarning { style = warnStyle } else if component.Level == healthError { style = badStyle } else if component.Level == healthInfo || component.Level == healthUnknown { style = dimStyle } text := healthComponentText(component) wrapped := ansi.Hardwrap(ansi.Wordwrap(text, width, ""), width, false) for _, line := range strings.Split(wrapped, "\n") { lines = append(lines, style.Render(line)) } } lines = append(lines, "", titleStyle.Render("Warnings and errors")) if len(m.healthEvents) == 0 { lines = append(lines, dimStyle.Render("No warnings or errors recorded this session.")) } for index := len(m.healthEvents) - 1; index >= 0; index-- { event := m.healthEvents[index] text := fmt.Sprintf( "%s %-7s %s: %s", event.At.Local().Format("15:04:05"), healthLevelLabel(event.Level), event.Component, event.Message, ) wrapped := ansi.Hardwrap(ansi.Wordwrap(text, width, ""), width, false) style := warnStyle if event.Level == healthError { style = badStyle } for _, line := range strings.Split(wrapped, "\n") { lines = append(lines, style.Render(line)) } } return lines } func (m App) dashboardHeaderLines() []string { pr := m.details width := max(10, m.width-2) draft := "" if pr.IsDraft { draft = " " + warnStyle.Render("DRAFT") } lines := []string{ titleStyle.Render(fmt.Sprintf("%s #%d", pr.RepoWithOwner, pr.Number)) + draft, titleStyle.Render(truncate(pr.Title, width)), } if pr.FromCache { lines = append(lines, warnStyle.Render( "OFFLINE CACHE • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04"), )) } if count := m.mutations.count(); count > 0 { lines = append(lines, warnStyle.Render(fmt.Sprintf("%d queued mutation(s) pending", count))) } if len(pr.DataIssues) > 0 { lines = append(lines, warnStyle.Render(fmt.Sprintf( "PARTIAL DATA • %d subsection(s) unavailable; press %s for details", len(pr.DataIssues), primaryKeyLabel(m.keybindings.Views.Health), ))) } return m.wrapHeaderLines(lines) } func (m App) dashboardLines() []string { if m.writeMode == writePREdit { return m.dashboardEditLines() } pr := m.details width := max(10, m.width-2) lines := m.dashboardHeaderLines() if m.loading && pr.BaseRef == "" { return append(lines, "", "Loading pull request details…") } open, outdated, resolved := threadStatusCounts(pr.Threads) created := "unknown" if !pr.CreatedAt.IsZero() { created = pr.CreatedAt.Local().Format("2006-01-02 15:04") } updated := "unknown" if !pr.UpdatedAt.IsZero() { updated = pr.UpdatedAt.Local().Format("2006-01-02 15:04") } milestone := firstNonEmpty(pr.Milestone, "none") labels := "none" if len(pr.Labels) > 0 { labels = strings.Join(pr.Labels, ", ") } lines = append(lines, "", dashboardMetadata("author", m.authorText(pr.Author)), dashboardMetadata("branches", pr.HeadRef+" → "+pr.BaseRef), dashboardMetadata("review", reviewAndMergeState(pr)), dashboardMetadata("checks", coloredState(pr.CheckState)), dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")), dashboardMetadata("auto-merge", m.autoMergeStateText(pr)), ) lines = append(lines, dashboardMetadataLines("conflicts", conflictStateText(pr), width)...) lines = append(lines, dashboardMetadata("assignees", m.handlesText(pr.Assignees)), dashboardMetadata("reviewers", m.reviewersText(pr.Reviewers)), dashboardMetadata("labels", labels), dashboardMetadata("milestone", milestone), dashboardMetadata("activity", fmt.Sprintf( "%d commits • %d conversation comments", pr.CommitCount, pr.CommentCount, )), dashboardMetadata("changes", fmt.Sprintf( "%s %s • %d files", okStyle.Render(fmt.Sprintf("+%d", pr.Additions)), badStyle.Render(fmt.Sprintf("-%d", pr.Deletions)), pr.ChangedFiles, )), dashboardMetadata("threads", fmt.Sprintf( "%d open • %d outdated • %d resolved", open, outdated, resolved, )), dashboardMetadata("requirements", mergeRequirementsText(pr.Requirements)), dashboardMetadata("permission", viewerPermissionsText(pr.Permissions)), dashboardMetadata("head sha", shortOID(pr.HeadOID)), dashboardMetadata("created", created), dashboardMetadata("updated", updated), dashboardMetadata("url", pr.URL), ) if len(pr.ConflictFiles) > 0 { lines = append(lines, "", titleStyle.Render(fmt.Sprintf( "Conflicting files (%d)", len(pr.ConflictFiles), )), "") for _, file := range pr.ConflictFiles { parts := strings.Split(ansi.Hardwrap(file, max(1, width-4), false), "\n") for index, part := range parts { prefix := " " if index == 0 { prefix = "• " } lines = append(lines, badStyle.Render(prefix)+part) } } } lines = append(lines, "", titleStyle.Render("Write capability gate"), "") lines = append(lines, writeCapabilityLines(pr, m.selectedThread())...) if pr.ThreadsTruncated { lines = append(lines, warnStyle.Render("Thread totals only include the first 100 review threads.")) } lines = append(lines, "", titleStyle.Render("Description"), "") if strings.TrimSpace(pr.Body) == "" { lines = append(lines, dimStyle.Render("No description provided.")) } else { lines = append(lines, renderCommentMarkdown(pr.Body, width)...) } lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Checks (%d)", len(pr.Checks))), "") if len(pr.Checks) == 0 { lines = append(lines, dimStyle.Render("No individual checks reported.")) } for _, check := range pr.Checks { line := coloredState(check.State) + " " + check.Name if check.URL != "" { line += " " + dimStyle.Render(check.URL) } lines = append(lines, line) if check.Summary != "" && check.State != "SUCCESS" { lines = append(lines, dimStyle.Render(" "+truncate(strings.Join(strings.Fields(check.Summary), " "), width-2))) } for _, annotation := range check.Annotations { location := fmt.Sprintf("%s:%d", annotation.Path, annotation.StartLine) lines = append(lines, " "+coloredState(strings.ToUpper(annotation.Level))+" "+ location+" "+truncate(firstNonEmpty(annotation.Title, annotation.Message), max(10, width-len(location)-8))) } } lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Commit and force-push timeline (%d)", len(pr.Timeline))), "") if len(pr.Timeline) == 0 { lines = append(lines, dimStyle.Render("No commit timeline events reported.")) } for _, event := range pr.Timeline { when := event.CreatedAt.Local().Format("2006-01-02 15:04") if event.Kind == "force-push" { lines = append(lines, warnStyle.Render("force-push")+" "+shortOID(event.BeforeOID)+" → "+ shortOID(event.AfterOID)+" "+m.authorText(event.Author)+" "+dimStyle.Render(when)) } else { lines = append(lines, shortOID(event.OID)+" "+truncate(event.Title, max(10, width-35))+ " "+m.authorText(event.Author)+" "+dimStyle.Render(when)) } } lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Applicable rulesets (%d)", applicableRulesetCount(pr.Rulesets))), "") for _, ruleset := range pr.Rulesets { if ruleset.Applies { lines = append(lines, ruleset.Name+" "+dimStyle.Render(strings.ToLower(ruleset.Enforcement))+ " "+strings.Join(ruleset.RuleTypes, ", ")) } } if applicableRulesetCount(pr.Rulesets) == 0 { lines = append(lines, dimStyle.Render("No applicable active rulesets reported.")) } if pr.MergeQueue != nil { lines = append(lines, dashboardMetadata("merge queue", fmt.Sprintf( "%s, position %d", strings.ToLower(pr.MergeQueue.State), pr.MergeQueue.Position, ))) } lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Submitted reviews (%d)", len(pr.Reviews))), "") if len(pr.Reviews) == 0 { lines = append(lines, dimStyle.Render("No submitted reviews.")) } if m.compactReviews { lines = append(lines, m.compactReviewLines(pr.Reviews, width)...) if len(pr.Reviews) > 0 { lines = append(lines, "") } } else { for _, review := range pr.Reviews { header := m.authorText(review.Author) + " " + coloredState(review.State) if !review.SubmittedAt.IsZero() { header += " " + dimStyle.Render(review.SubmittedAt.Local().Format("2006-01-02 15:04")) } if review.CommitOID != "" { header += " " + dimStyle.Render(shortOID(review.CommitOID)) } lines = append(lines, header) if strings.TrimSpace(review.Body) != "" { lines = append(lines, renderCommentMarkdown(review.Body, width)...) } lines = append(lines, "") } } lines = append(lines, titleStyle.Render(fmt.Sprintf("Conversation (%d)", len(pr.Conversation))), "") if len(pr.Conversation) == 0 { lines = append(lines, dimStyle.Render("No PR conversation comments.")) } for _, comment := range pr.Conversation { header := m.authorText(comment.Author) if !comment.CreatedAt.IsZero() { header += " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")) } lines = append(lines, header) lines = append(lines, renderCommentMarkdown(comment.Body, width)...) lines = append(lines, "") } return lines } func (m App) compactReviewLines(reviews []ReviewSummary, width int) []string { if len(reviews) == 0 { return nil } stateCounts := make(map[string]int) authorCounts := make(map[string]int) for _, review := range reviews { stateCounts[review.State]++ authorCounts[review.Author]++ } states := make([]string, 0, len(stateCounts)) for state := range stateCounts { states = append(states, state) } sort.Strings(states) authors := make([]string, 0, len(authorCounts)) for author := range authorCounts { authors = append(authors, author) } sort.Slice(authors, func(i, j int) bool { if authorCounts[authors[i]] != authorCounts[authors[j]] { return authorCounts[authors[i]] > authorCounts[authors[j]] } return strings.ToLower(authors[i]) < strings.ToLower(authors[j]) }) summary := make([]string, 0, len(states)+len(authors)) for _, state := range states { summary = append(summary, coloredState(state)+dimStyle.Render(fmt.Sprintf(" ×%d", stateCounts[state]))) } for _, author := range authors { summary = append(summary, m.authorText(author)+dimStyle.Render(fmt.Sprintf(" ×%d", authorCounts[author])), ) } lines := []string{ansi.Truncate(strings.Join(summary, dimStyle.Render(" • ")), width, "…")} for _, review := range reviews { if body := compactReviewBody(review.Body, width); body != "" { line := m.authorText(review.Author) + " " + coloredState(review.State) + dimStyle.Render(" — ") + body lines = append(lines, ansi.Truncate(line, width, "…")) } } return lines } func compactReviewBody(body string, width int) string { if strings.TrimSpace(body) == "" { return "" } rendered := ansi.Strip(strings.Join(renderCommentMarkdown(body, width), " ")) return strings.Join(strings.Fields(rendered), " ") } func dashboardMetadata(label, value string) string { return titleStyle.Render(pad(label+":", 13)) + " " + value } func dashboardMetadataLines(label, value string, width int) []string { const labelWidth = 13 valueWidth := max(1, width-labelWidth-1) wrapped := ansi.Hardwrap(ansi.Wordwrap(value, valueWidth, ""), valueWidth, false) parts := strings.Split(wrapped, "\n") lines := make([]string, 0, len(parts)) for index, part := range parts { currentLabel := "" if index == 0 { currentLabel = label + ":" } lines = append(lines, titleStyle.Render(pad(currentLabel, labelWidth))+" "+part) } return lines } func threadStatusCounts(threads []ReviewThread) (open, outdated, resolved int) { for _, thread := range threads { switch threadStatus(thread) { case "resolved": resolved++ case "outdated": outdated++ default: open++ } } return open, outdated, resolved } func mergeRequirementsText(requirements MergeRequirements) string { var items []string if requirements.RequiresApprovals { approval := "approvals" if requirements.ApprovalsRequired == 1 { approval = "approval" } items = append(items, fmt.Sprintf("%d %s", requirements.ApprovalsRequired, approval)) } if requirements.RequiresCodeOwnerReview { items = append(items, "code owner review") } if requirements.RequiresStatusChecks { items = append(items, "status checks") } if requirements.RequiresStrictChecks { items = append(items, "up-to-date branch") } if requirements.RequiresDeployments { item := "deployments" if len(requirements.RequiredDeployments) > 0 { item += " (" + strings.Join(requirements.RequiredDeployments, ", ") + ")" } items = append(items, item) } if requirements.RequiresLinearHistory { items = append(items, "linear history") } if requirements.RequiresSignatures { items = append(items, "signed commits") } if requirements.RequiresMergeQueue { items = append(items, "merge queue") } if requirements.RequiresConversation { items = append(items, "resolved conversations") } if len(items) == 0 { return "none reported" } return strings.Join(items, ", ") } func applicableRulesetCount(rulesets []Ruleset) int { count := 0 for _, ruleset := range rulesets { if ruleset.Applies { count++ } } return count } type writeCapability struct { name, reason string enabled bool } func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability { threadReason := "select a review thread" canReply, canResolve := false, false resolveName := "resolve thread" if thread != nil { canReply = thread.ViewerCanReply if thread.IsResolved { resolveName = "unresolve thread" canResolve = thread.ViewerCanUnresolve } else { canResolve = thread.ViewerCanResolve } threadReason = "GitHub did not grant permission for this thread" } autoMergeAllowed := !pr.Merged && pr.State != "CLOSED" && (pr.Permissions.CanEnableMerge || pr.Permissions.CanDisableMerge) autoMergeReason := "auto-merge is unavailable for this PR" if pr.Merged || pr.State == "CLOSED" { autoMergeReason = "pull request is already closed" } if pr.FromCache { threadReason = "saved snapshot did not grant this thread permission" autoMergeAllowed = false autoMergeReason = "auto-merge remains online-only" } mergeAllowed := !pr.FromCache && mergeNowStateReason(pr) == "" mergeReason := firstNonEmpty(mergeNowStateReason(pr), "available") if pr.FromCache { mergeReason = "merge remains online-only" } return []writeCapability{ capability("reply", canReply, threadReason, true), capability(resolveName, canResolve, threadReason, true), capability("react", pr.Permissions.CanReact && !pr.FromCache, "reactions remain online-only", false), capability("update pull request", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", true), capability("auto-merge", autoMergeAllowed, autoMergeReason, true), capability("merge now", mergeAllowed, mergeReason, true), } } func capability(name string, allowed bool, denied string, implemented bool) writeCapability { if !allowed { return writeCapability{name: name, reason: denied} } if !implemented { return writeCapability{name: name, reason: "write action not implemented yet"} } return writeCapability{name: name, enabled: true, reason: "available"} } func writeCapabilityLines(pr PRDetails, thread *ReviewThread) []string { var lines []string for _, item := range writeCapabilities(pr, thread) { marker := badStyle.Render("disabled") if item.enabled { marker = okStyle.Render("ready") } lines = append(lines, pad(item.name, 21)+" "+marker+" "+dimStyle.Render(item.reason)) } return lines } func (m App) selectedThread() *ReviewThread { if m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) { return nil } thread := m.details.Threads[m.threadIndex] return &thread } func viewerPermissionsText(permissions ViewerPermissions) string { items := []string{strings.ToLower(firstNonEmpty(permissions.Repository, "unknown"))} if permissions.CanUpdatePR { items = append(items, "update") } if permissions.CanResolveAny { items = append(items, "resolve") } if permissions.CanUnresolveAny { items = append(items, "unresolve") } if permissions.CanReplyAny { items = append(items, "reply") } if permissions.CanEnableMerge { items = append(items, "enable auto-merge") } if permissions.CanDisableMerge { items = append(items, "disable auto-merge") } return strings.Join(items, ", ") } func (m App) threadTopLines() []string { pr := m.details width := m.width if m.headerWidth > 0 { width = m.headerWidth } header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, width-len(pr.RepoWithOwner)-12)))) meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr)) people := "assignees: " + m.handlesText(pr.Assignees) + " reviewers: " + m.reviewersText(pr.Reviewers) top := []string{header, meta, people} if pr.FromCache { top = append(top, warnStyle.Render( "CACHED snapshot • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04")+ " • refreshing live data", )) } if count := m.mutations.count(); count > 0 { top = append(top, warnStyle.Render(fmt.Sprintf("%d queued mutation(s) pending", count))) } if pr.ThreadsTruncated { top = append(top, warnStyle.Render("Showing the first 100 review threads.")) } return m.wrapHeaderLines(top) } func (m App) viewThreads() string { top := m.threadTopLines() topLineCount := len(top) if m.headerWidth > 0 { topLineCount++ } contentHeight := max(3, m.height-topLineCount-1) var body string if m.width < 70 { if m.focus == threadListPane { body = m.threadList(max(3, m.width), contentHeight) } else { body = m.threadDetail(max(3, m.width), contentHeight) } } else if m.listHidden { body = m.threadDetail(m.width, contentHeight) } else { leftWidth := m.threadListWidth() rightWidth := max(20, m.width-leftWidth-1) left := m.threadList(leftWidth, contentHeight) right := m.threadDetail(rightWidth, contentHeight) body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) } help := fmt.Sprintf( "%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), primaryKeyLabel(m.keybindings.General.Back), primaryKeyLabel(m.keybindings.General.Quit), ) if m.searching { help = fmt.Sprintf( "path words • status:open • author:name • updated:true • %s apply • %s cancel", primaryKeyLabel(m.keybindings.Input.Newline), primaryKeyLabel(m.keybindings.Input.Cancel), ) } else if m.writeMode == writeReply { if m.editorMode == "vim" { help = fmt.Sprintf( "%s review • %s normal/cancel", primaryKeyLabel(m.keybindings.Input.Submit), primaryKeyLabel(m.keybindings.Input.Cancel), ) } else { help = fmt.Sprintf( "reply inline • arrows move • %s newline • %s review • %s cancel", primaryKeyLabel(m.keybindings.Input.Newline), primaryKeyLabel(m.keybindings.Input.Submit), primaryKeyLabel(m.keybindings.Input.Cancel), ) } } else if m.aiMode == aiDiscussion { help = fmt.Sprintf( "local AI discussion • arrows move • %s newline • %s prepare • %s %s", primaryKeyLabel(m.keybindings.Input.Newline), primaryKeyLabel(m.keybindings.Input.Submit), primaryKeyLabel(m.keybindings.Input.Cancel), m.inputCancelAction(), ) } m.positionThreadInputHardwareCursor() if m.headerWidth > 0 { top = append(top, "") } view := m.frame(append(top, body), help) if m.cursorOutput != nil { view += m.cursorOutput.FrameMarker() } return view } func (m App) threadList(width, height int) string { innerWidth := max(1, width-2) innerHeight := max(1, height-2) matches := m.matchingThreadIndices() lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d/%d)", len(matches), len(m.details.Threads)))} if m.searching { queryWidth := max(1, innerWidth-len("Filter: ")-1) query := renderSingleLineInput(m.searchEditor, queryWidth, m.cursorOutput != nil) lines = append(lines, titleStyle.Render("Filter: ")+query) } if m.loading && len(m.details.Threads) == 0 { lines = append(lines, "Loading…") } selectedPosition := 0 for position, index := range matches { if index == m.threadIndex { selectedPosition = position break } } available := max(1, innerHeight-len(lines)) start := windowStart(selectedPosition, len(matches), available) if len(matches) == 0 && m.searchQuery != "" { lines = append(lines, dimStyle.Render("No matching threads.")) } for _, i := range matches[start:min(len(matches), start+available)] { thread := m.details.Threads[i] icon := "●" if thread.IsResolved { icon = "✓" } else if thread.IsOutdated { icon = "○" } suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments)) if m.unreadThreads[thread.ID] { label := "NEW" if m.newThreads[thread.ID] { label = "NEW THREAD" } suffix += " " + warnStyle.Render(label) } pathWidth := max(4, innerWidth-lipgloss.Width(suffix)-2) path := truncatePath(thread.Path, pathWidth) if m.pathScroll { path = scrollingPath(thread.Path, pathWidth, m.pathScrollStep) } line := icon + " " + pad(path, pathWidth) + suffix line = ansi.Truncate(line, innerWidth, "") if thread.IsResolved { line = dimStyle.Render(line) } if i == m.threadIndex { line = activeStyle.Render(pad(line, innerWidth)) } lines = append(lines, line) } return renderPane(lines, width, height, m.focus == threadListPane) } func (m App) threadDetail(width, height int) string { if len(m.details.Threads) == 0 { return renderPane([]string{"No review threads."}, width, height, m.focus == threadDetailPane) } lines := m.renderedDetailLines(width) viewportHeight := max(1, height-2) maxScroll := max(0, len(lines)-viewportHeight) scroll := min(m.scroll, maxScroll) visible := lines[scroll:min(len(lines), scroll+viewportHeight)] rendered := make([]string, 0, len(visible)) innerWidth := max(1, width-2) for _, line := range visible { railWidth := ansi.StringWidth(line.rail) contentWidth := max(1, innerWidth-railWidth) var renderedLine string if line.suggestionChange != 0 { renderedLine = suggestionHighlight(line.fixed, line.text, contentWidth, line.suggestionChange) } else { renderedLine = ansi.Truncate(line.fixed+line.text, contentWidth, "") } renderedLine = line.rail + renderedLine if line.selected { renderedLine = selectedBackground(renderedLine, innerWidth) } rendered = append(rendered, renderedLine) } return renderPane(rendered, width, height, m.focus == threadDetailPane) } type detailLine struct { text string fixed string rail string selected bool suggestionChange byte anchor string } func (m App) detailLines(width int) []detailLine { if len(m.details.Threads) == 0 { return nil } thread := m.details.Threads[m.threadIndex] status := "open" if thread.IsResolved { status = "resolved" } if thread.IsOutdated { status += ", outdated" } if thread.Origin == reviewOriginLocalAI { status += ", LOCAL AI · LOCAL ONLY" } if thread.Pending { status += ", PENDING" } if m.unreadThreads[thread.ID] { if m.newThreads[thread.ID] { status += ", new thread" } else { status += ", new updates" } } if len(thread.Comments) > 0 && thread.Comments[0].OriginalCommitOID != "" { status += ", snapshot " + shortOID(thread.Comments[0].OriginalCommitOID) } if pushedAt := latestForcePush(m.details.Timeline); !pushedAt.IsZero() && threadOpenedAt(thread).Before(pushedAt) { status += ", predates latest force-push" } lines := []detailLine{ {anchor: "header", text: titleStyle.Render(fmt.Sprintf("[%d/%d] %s:%d", m.threadIndex+1, len(m.details.Threads), truncatePath(thread.Path, max(8, width-24)), thread.Line)) + " " + dimStyle.Render(status)}, } if m.folded[thread.ID] { lines = append(lines, detailLine{}, detailLine{text: dimStyle.Render(fmt.Sprintf( "Thread folded. Press %s to expand.", primaryCombinedKeyLabel( m.keybindings.Threads.Toggle, []string{primarySequenceKeyLabel( m.keybindings.Threads.FoldPrefix, m.keybindings.Threads.FoldToggle, )}, ), ))}) } else { if len(thread.Comments) > 0 { lines = append(lines, detailLine{}) startLine, endLine := reviewAnchor(thread) for codeIndex, codeLine := range highlightDiff(thread.Path, thread.Comments[0].DiffHunk, startLine, endLine, thread.DiffSide) { wrapped := wrapDiffLine(codeLine, max(1, width-2)) for wrapIndex := range wrapped { wrapped[wrapIndex].anchor = fmt.Sprintf("code:%d:%d", codeIndex, wrapIndex) } lines = append(lines, wrapped...) } } unreadDivider := false for _, comment := range thread.Comments { commentUnread := m.unreadComments[comment.ID] if commentUnread && !m.newThreads[thread.ID] && !unreadDivider { lines = append(lines, detailLine{}, detailLine{ anchor: "unread:" + comment.ID, text: warnStyle.Render("── NEW MESSAGES ──"), }, ) unreadDivider = true } content := parseCommentBody(comment.Body) rail := lipgloss.NewStyle().Foreground(authorColor(comment.Author)).Render("│ ") if commentUnread && !m.newThreads[thread.ID] { rail = warnStyle.Render("┃ ") } lines = append(lines, detailLine{}, detailLine{ rail: rail, anchor: "comment:" + comment.ID + ":header", text: m.commentAuthorText(comment) + localAICommentBadge(comment) + " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")), }) if content.Prose != "" { for lineIndex, commentLine := range renderCommentMarkdown(content.Prose, max(10, width-4)) { lines = append(lines, detailLine{ rail: rail, text: commentLine, anchor: fmt.Sprintf("comment:%s:body:%d", comment.ID, lineIndex), }) } } for suggestionIndex, suggestion := range content.Suggestions { label := "suggested change" if len(content.Suggestions) > 1 { label = fmt.Sprintf("suggested change %d/%d", suggestionIndex+1, len(content.Suggestions)) } lines = append(lines, detailLine{rail: rail}, detailLine{rail: rail, text: dimStyle.Render(label)}) rendered := renderSuggestion( thread.Path, reviewedSourceLines(thread, comment), suggestion, max(1, width-4), ) lines = append(lines, addCommentRail(rendered.removed, rail)...) lines = append(lines, addCommentRail(rendered.added, rail)...) } for reactionIndex, reactionLine := range renderReactionSummary( comment.Reactions, max(1, width-4), ) { lines = append(lines, detailLine{ rail: rail, text: reactionLine, anchor: fmt.Sprintf("comment:%s:reactions:%d", comment.ID, reactionIndex), }) } } if thread.IsTruncated { lines = append(lines, detailLine{}, detailLine{text: warnStyle.Render("Showing the first 100 comments in this thread.")}) } } 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 } func (m App) renderedDetailLines(width int) []detailLine { lines := m.detailLines(width) innerWidth := max(1, width-2) rendered := make([]detailLine, 0, len(lines)) for _, line := range lines { contentWidth := max(1, innerWidth-ansi.StringWidth(line.rail)) content := line.fixed + line.text if line.suggestionChange != 0 || ansi.StringWidth(content) <= contentWidth { rendered = append(rendered, line) continue } for index, wrapped := range strings.Split( ansi.Hardwrap(content, contentWidth, true), "\n", ) { continuation := line continuation.fixed = "" continuation.text = wrapped if index > 0 && continuation.anchor != "" { continuation.anchor += fmt.Sprintf(":wrap:%d", index) } rendered = append(rendered, continuation) } } return rendered } func renderReactionSummary(reactions []ReactionSummary, width int) []string { var badges []string for _, reaction := range reactions { if reaction.Count <= 0 { continue } badge := fmt.Sprintf("%s %d", reactionEmoji(reaction.Content), reaction.Count) if reaction.ViewerHasReacted { badge = titleStyle.Render(badge) } else { badge = dimStyle.Render(badge) } badges = append(badges, badge) } if len(badges) == 0 { return nil } lines := []string{} current := "" for _, badge := range badges { candidate := badge if current != "" { candidate = current + " " + badge } if current != "" && ansi.StringWidth(candidate) > width { lines = append(lines, current) current = badge } else { current = candidate } } if current != "" { lines = append(lines, current) } return lines } func reactionEmoji(content string) string { switch content { case "THUMBS_UP": return "👍" case "THUMBS_DOWN": return "👎" case "LAUGH": return "😄" case "HOORAY": return "🎉" case "CONFUSED": return "😕" case "HEART": return "❤️" case "ROCKET": return "🚀" case "EYES": return "👀" default: return ":" + strings.ToLower(content) + ":" } } func (m App) inlineReplyLines(width int) []detailLine { rail := warnStyle.Render("│ ") lines := []detailLine{ {}, {rail: rail, anchor: "reply:header", text: titleStyle.Render("Reply draft")}, } textWidth := max(1, width-5) lineIndex := 0 for _, part := range renderTextInput( m.replyEditor, textWidth, m.cursorOutput != nil, ) { lines = append(lines, detailLine{ rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part, }) lineIndex++ } if m.err != nil { lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())}) } if m.editorMode != "vim" { lines = append(lines, detailLine{ rail: rail, text: dimStyle.Render(fmt.Sprintf( "%s newline • %s review • %s %s", primaryKeyLabel(m.keybindings.Input.Newline), primaryKeyLabel(m.keybindings.Input.Submit), primaryKeyLabel(m.keybindings.Input.Cancel), m.inputCancelAction(), )), }) } return lines } func renderTextInput(editor textEditor, width int, hardwareCursor bool) []string { editor.hardwareCursor = hardwareCursor rendered := renderTextEditor(editor, width, true) lines := make([]string, 0, len(rendered)) for _, line := range rendered { lines = append(lines, line.text) } return lines } func renderSingleLineInput(editor textEditor, width int, hardwareCursor bool) string { editor.hardwareCursor = hardwareCursor rendered := renderTextEditor(editor, width, true) return rendered[editorCursorVisualLine(editor, width)].text } func (m App) threadInputWidth() int { width, _ := m.detailPaneSize() return max(1, width-5) } func (m *App) ensureThreadInputCursorVisible() { if m.writeMode != writeReply && m.aiMode != aiDiscussion { return } width, height := m.detailPaneSize() editor, prefix := m.replyEditor, "reply:body:" if m.aiMode == aiDiscussion { editor, prefix = m.aiInputEditor, "ai-discussion:body:" } visualLine := editorCursorVisualLine(editor, m.threadInputWidth()) wantedAnchor := fmt.Sprintf("%s%d", prefix, visualLine) lines := m.renderedDetailLines(width) cursorLine := -1 for index := range lines { if lines[index].anchor == wantedAnchor { cursorLine = index break } } if cursorLine < 0 { return } viewportHeight := max(1, height-2) switch { case cursorLine < m.scroll: m.scroll = cursorLine case cursorLine >= m.scroll+viewportHeight: m.scroll = cursorLine - viewportHeight + 1 } m.scroll = clamp(m.scroll, 0, max(0, len(lines)-viewportHeight)) } func (m App) positionThreadInputHardwareCursor() { if m.cursorOutput == nil { return } topLines := m.threadTopLineCount() if m.searching { if m.width >= 70 && m.listHidden { return } paneWidth := m.width if m.width >= 70 { paneWidth = m.threadListWidth() } queryWidth := max(1, max(1, paneWidth-2)-len("Filter: ")-1) if m.searchEditor.Mode != textEditorInsert { return } _, column := editorCursorVisualPosition(m.searchEditor, queryWidth) m.cursorOutput.SetCursor( true, 2+ansi.StringWidth("Filter: ")+column, m.contentTop+topLines+3, ) return } if m.writeMode != writeReply && m.aiMode != aiDiscussion { return } width, height := m.detailPaneSize() lines := m.renderedDetailLines(width) prefix := "reply:body:" if m.aiMode == aiDiscussion { prefix = "ai-discussion:body:" } editor := m.replyEditor if m.aiMode == aiDiscussion { editor = m.aiInputEditor } if editor.Mode != textEditorInsert { return } visualLine, column := editorCursorVisualPosition(editor, m.threadInputWidth()) cursorLine := -1 wantedAnchor := fmt.Sprintf("%s%d", prefix, visualLine) for index := range lines { if lines[index].anchor == wantedAnchor { cursorLine = index break } } if cursorLine < 0 { return } viewportHeight := max(1, height-2) scroll := min(m.scroll, max(0, len(lines)-viewportHeight)) screenLine := cursorLine - scroll if screenLine < 0 || screenLine >= viewportHeight { return } paneStart := 0 if m.width >= 70 && !m.listHidden { paneStart = m.threadListWidth() + 1 } line := lines[cursorLine] m.cursorOutput.SetCursor( true, paneStart+2+ansi.StringWidth(line.rail+line.fixed)+column, m.contentTop+topLines+2+screenLine, ) } func latestForcePush(events []TimelineEvent) time.Time { var latest time.Time for _, event := range events { if event.Kind == "force-push" && event.CreatedAt.After(latest) { latest = event.CreatedAt } } return latest } func (m App) detailScrollAnchor() string { width, _ := m.detailPaneSize() lines := m.renderedDetailLines(width) for index := min(m.scroll, len(lines)-1); index >= 0; index-- { if lines[index].anchor != "" { return lines[index].anchor } } return "" } func (m *App) restoreDetailAnchor(anchor string) { width, _ := m.detailPaneSize() for index, line := range m.renderedDetailLines(width) { if line.anchor == anchor { m.scroll = min(index, m.detailMaxScroll()) return } } m.scroll = min(m.scroll, m.detailMaxScroll()) } func wrapDiffLine(line highlightedDiffLine, width int) []detailLine { gutterWidth := ansi.StringWidth(line.gutter) if gutterWidth == 0 { wrapped := ansi.Hardwrap(line.code, width, true) parts := strings.Split(wrapped, "\n") result := make([]detailLine, 0, len(parts)) for _, part := range parts { result = append(result, detailLine{text: part, selected: line.selected}) } return result } codeWidth := max(1, width-gutterWidth) parts := wrapCodeWithIndent(line.code, codeWidth) result := make([]detailLine, 0, len(parts)) for i, part := range parts { gutter := line.gutter if i > 0 { gutter = strings.Repeat(" ", gutterWidth) } result = append(result, detailLine{ text: part, fixed: gutter, selected: line.selected, }) } return result } func wrapSuggestionLine(path, source string, change byte, changed codeRange, width int) []detailLine { marker := badStyle.Render(string(change)) if change == '+' { marker = okStyle.Render(string(change)) } code := highlightedSource(lexerForPath(path), source) code = highlightCodeRange(code, changed, change) lines := wrapDiffLine(highlightedDiffLine{ gutter: marker + " ", code: code, }, width) for i := range lines { lines[i].suggestionChange = change } return lines } func highlightCodeRange(code string, changed codeRange, change byte) string { if changed.End <= changed.Start { return code } totalWidth := ansi.StringWidth(code) start := clamp(changed.Start, 0, totalWidth) end := clamp(changed.End, start, totalWidth) if end <= start { return code } background := changedRemoveBackground if change == '+' { background = changedAddBackground } if background == "" { return code } const reset = "\x1b[0m" before := ansi.Cut(code, 0, start) middle := ansi.Cut(code, start, end) after := ansi.Cut(code, end, totalWidth) middle = strings.ReplaceAll(middle, reset, reset+background) return before + background + middle + reset + after } func addCommentRail(lines []detailLine, rail string) []detailLine { for i := range lines { lines[i].rail = rail } return lines } func wrapCodeWithIndent(code string, width int) []string { if width <= 0 || ansi.StringWidth(code) <= width { return []string{code} } plain := ansi.Strip(code) indent := len(plain) - len(strings.TrimLeft(plain, " ")) totalWidth := ansi.StringWidth(code) continuationPrefix := strings.Repeat(" ", indent+2) + dimStyle.Render("↳ ") continuationWidth := max(1, width-ansi.StringWidth(continuationPrefix)) parts := make([]string, 0, totalWidth/width+1) offset := 0 for offset < totalWidth { available := width prefix := "" if offset > 0 { available = continuationWidth prefix = continuationPrefix } end := syntaxBreakColumn(plain, offset, available) if end <= offset { end = min(totalWidth, offset+available) } parts = append(parts, prefix+ansi.Cut(code, offset, end)) offset = end } return parts } func syntaxBreakColumn(code string, start, width int) int { limit := start + width column := 0 best := -1 for _, r := range code { column += ansi.StringWidth(string(r)) if column <= start { continue } if column > limit { break } if isCodeBreakpoint(r) { best = column } } if best > start { return best } return min(ansi.StringWidth(code), limit) } func isCodeBreakpoint(r rune) bool { if r == ' ' || r == '\t' { return true } return strings.ContainsRune(",.;:()[]{}+-*/%=<>|&", r) } func reviewAnchor(thread ReviewThread) (int, int) { if len(thread.Comments) > 0 { return commentReviewAnchor(thread, thread.Comments[0]) } return commentReviewAnchor(thread, ReviewComment{}) } func shortOID(oid string) string { if len(oid) <= 7 { return oid } return oid[:7] } func selectedBackground(line string, width int) string { const reset = "\x1b[0m" background := selectedLineBackground line = pad(ansi.Truncate(line, width, ""), width) if background == "" { return line } line = strings.ReplaceAll(line, reset, reset+background) return background + line + reset } func suggestionHighlight(gutter, code string, width int, change byte) string { background := suggestionRemoveBackground if change == '+' { background = suggestionAddBackground } if background == "" { return pad(ansi.Truncate(gutter+code, width, ""), width) } const reset = "\x1b[0m" gutter = ansi.Truncate(gutter, width, "") gutter = strings.ReplaceAll(gutter, reset, reset+background) codeWidth := max(0, width-ansi.StringWidth(gutter)) code = ansi.Truncate(code, codeWidth, "") code = strings.ReplaceAll(code, reset, reset+background) content := background + gutter + code + reset padding := max(0, width-ansi.StringWidth(gutter)-ansi.StringWidth(code)) if padding > 0 { content += background + strings.Repeat(" ", padding) + reset } return content } var authorPalette = []lipgloss.Color{ "#61AFEF", "#C678DD", "#56B6C2", "#E5C07B", "#E06C75", "#98C379", "#D19A66", "#7FC8FF", } func authorStyle(login string) lipgloss.Style { return lipgloss.NewStyle().Bold(true).Foreground(authorColor(login)) } func (m App) viewerLogin() string { if m.details.ViewerLogin != "" { return m.details.ViewerLogin } if m.details.ViewerAuthored { return m.details.Author } return "" } func (m App) displayAuthor(login string) string { if m.viewerLabel == "you" && strings.EqualFold(login, m.viewerLogin()) { return "you" } return login } func (m App) authorText(login string) string { return authorStyle(login).Render("@" + m.displayAuthor(login)) } func (m App) commentAuthorText(comment ReviewComment) string { login := comment.Author if comment.Origin == reviewOriginLocalAIUser && m.viewerLogin() != "" { login = m.viewerLogin() } return m.authorText(login) } func authorColor(login string) lipgloss.Color { hash := fnv.New32a() _, _ = hash.Write([]byte(strings.ToLower(login))) return authorPalette[int(hash.Sum32())%len(authorPalette)] } func (m App) frame(lines []string, help string) string { body := strings.Join(lines, "\n") status := "" if m.err != nil { status = badStyle.Render("error: " + truncate(m.err.Error(), max(20, m.width-8))) } // Keep an already-rendered screen byte-for-byte stable while a background // refresh starts. Changing only this footer on a full-height alternate // screen makes some terminals clear and repaint the entire frame. initialLoad := m.lastRefresh.IsZero() if status == "" && m.loading && initialLoad { status = warnStyle.Render("refreshing…") } else if status == "" && m.secondaryLoading && initialLoad { status = warnStyle.Render("loading details…") } if status == "" && !m.lastRefresh.IsZero() { status = dimStyle.Render("updated " + m.lastRefresh.Format("15:04:05")) } footerWidth := m.width if status != "" { footerWidth = max(0, m.width-lipgloss.Width(status)-2) } footer := m.renderFooter(help, footerWidth) if status != "" { footer += " " + status } bodyLines := strings.Split(body, "\n") bodyHeight := max(0, m.height-1) if len(bodyLines) > bodyHeight { bodyLines = bodyLines[:bodyHeight] } for i := range bodyLines { bodyLines[i] = ansi.Truncate(bodyLines[i], m.width, "") } for len(bodyLines) < bodyHeight { bodyLines = append(bodyLines, "") } bodyLines = append(bodyLines, ansi.Truncate(footer, m.width, "")) return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n")) } func (m App) renderFooter(help string, width int) string { if width <= 0 { return "" } editor, context, ok := m.activeInputEditor() if !ok { return dimStyle.Render(truncate(help, width)) } mode := activeStyle.Render(" " + editor.modeLabel() + " ") contextLabel := titleStyle.Render(context) prefix := mode + " " + contextLabel remaining := width - lipgloss.Width(prefix) if remaining <= 2 { return ansi.Truncate(prefix, width, "") } return prefix + " " + dimStyle.Render(truncate(help, remaining-2)) } func (m App) restingDiffletState() diffletState { if m.err != nil { return diffletSad } if m.loading || m.secondaryLoading { return diffletLoading } if m.details.Number == 0 { if !m.lastRefresh.IsZero() && len(m.prs) == 0 { return diffletSleeping } return diffletIdle } if m.details.ReviewDecision == "APPROVED" { return diffletApproved } open, outdated, resolved := threadStatusCounts(m.details.Threads) if open+outdated > 0 { return diffletConcerned } if resolved > 0 { return diffletHappy } if m.screen == threadScreen { return diffletFocused } return diffletIdle } func renderPane(lines []string, width, height int, active bool) string { innerWidth := max(1, width-2) innerHeight := max(1, height-2) if len(lines) > innerHeight { lines = lines[:innerHeight] } for i := range lines { lines[i] = ansi.Truncate(lines[i], innerWidth, "") } return paneStyle(active).Width(innerWidth).Height(innerHeight).Render(strings.Join(lines, "\n")) } func reviewAndMergeState(pr PRDetails) string { if pr.Merged { return okStyle.Render("merged") } switch pr.ReviewDecision { case "APPROVED": review := okStyle.Render("review: approved") open, outdated, _ := threadStatusCounts(pr.Threads) if pr.Requirements.RequiresConversation && open+outdated > 0 { return review + " " + warnStyle.Render("merge: unresolved conversations") } if pr.Requirements.RequiresStatusChecks && (pr.CheckState == "FAILURE" || pr.CheckState == "ERROR") { return review + " " + badStyle.Render("merge: checks failing") } switch pr.Mergeable { case "MERGEABLE": return review + " " + okStyle.Render("merge: ready") case "CONFLICTING": return review + " " + badStyle.Render("merge: conflicts") default: return review + " " + warnStyle.Render("merge: checking") } case "CHANGES_REQUESTED": return badStyle.Render("review: changes requested") case "REVIEW_REQUIRED": return warnStyle.Render("review: required") default: return warnStyle.Render("review: pending") } } func conflictStateText(pr PRDetails) string { switch pr.Mergeable { case "MERGEABLE": return okStyle.Render("none") case "CONFLICTING": if len(pr.ConflictFiles) > 0 { return badStyle.Render(fmt.Sprintf("%d conflicting files", len(pr.ConflictFiles))) } if pr.ConflictFileError != "" { return badStyle.Render("detected") + " " + warnStyle.Render("file scan unavailable: "+firstLine(pr.ConflictFileError)) } return badStyle.Render("detected") + " " + dimStyle.Render("no individual file paths reported") default: return warnStyle.Render("checking") } } func (m App) autoMergeStateText(pr PRDetails) string { if pr.Merged { return okStyle.Render("merged") } if pr.AutoMerge == nil { return dimStyle.Render("disabled") } text := okStyle.Render("enabled") + " " + strings.ToLower(pr.AutoMerge.MergeMethod) if pr.AutoMerge.EnabledBy != "" { text += " by " + m.authorText(pr.AutoMerge.EnabledBy) } return text } func firstLine(value string) string { if line, _, found := strings.Cut(value, "\n"); found { return line } return value } func coloredState(state string) string { switch state { case "SUCCESS", "EXPECTED", "COMPLETED", "NEUTRAL", "SKIPPED": return okStyle.Render(state) case "FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STALE": return badStyle.Render(state) default: return warnStyle.Render(state) } } func (m App) reviewersText(reviewers []Reviewer) string { if len(reviewers) == 0 { return "none" } items := make([]string, 0, len(reviewers)) for _, reviewer := range reviewers { items = append(items, m.authorText(reviewer.Login)+ dimStyle.Render(" ("+strings.ToLower(reviewer.State)+")"), ) } return strings.Join(items, ", ") } func (m App) handlesText(items []string) string { if len(items) == 0 { return "none" } handles := make([]string, 0, len(items)) for _, item := range items { handles = append(handles, m.authorText(item)) } return strings.Join(handles, ", ") } func indexPR(items []PullRequest, id string) int { for i, item := range items { if item.ID == id { return i } } return 0 } func indexThread(items []ReviewThread, id string) int { for i, item := range items { if item.ID == id { return i } } return 0 } func clamp(value, low, high int) int { if high < low { return low } return min(max(value, low), high) } func windowStart(index, count, size int) int { if count <= size { return 0 } return clamp(index-size/2, 0, count-size) } func truncate(s string, width int) string { if width <= 0 { return "" } return ansi.Truncate(strings.ReplaceAll(s, "\n", " "), width, "…") } func truncatePath(path string, width int) string { if width <= 0 { return "" } pathWidth := ansi.StringWidth(path) if pathWidth <= width { return path } if width == 1 { return "…" } return "…" + ansi.Cut(path, pathWidth-width+1, pathWidth) } func scrollingPath(path string, width, step int) string { const pauseSteps = 4 if width <= 0 { return "" } pathWidth := ansi.StringWidth(path) if pathWidth <= width { return path } if width == 1 { return "…" } visibleWidth := width - 1 maxOffset := pathWidth - visibleWidth cycle := pauseSteps + maxOffset + pauseSteps phase := step % cycle offset := 0 switch { case phase < pauseSteps: offset = 0 case phase < pauseSteps+maxOffset: offset = phase - pauseSteps default: offset = maxOffset } if offset == 0 { return ansi.Cut(path, 0, visibleWidth) + "…" } if offset == maxOffset { return "…" + ansi.Cut(path, pathWidth-visibleWidth, pathWidth) } return "…" + ansi.Cut(path, offset, offset+width-2) + "…" } func fuzzyPathScore(path, query string) (int, bool) { candidate := []rune(strings.ToLower(path)) terms := strings.Fields(strings.ToLower(query)) if len(terms) == 0 { return 0, true } total := 0 for _, term := range terms { score, ok := fuzzyTermScore(candidate, []rune(term)) if !ok { return 0, false } total += score } return total, true } func fuzzyTermScore(candidate, needle []rune) (int, bool) { if start := strings.Index(string(candidate), string(needle)); start >= 0 { return 10000 - start*10 - len(candidate), true } score, candidateIndex, previous := 0, 0, -2 for _, wanted := range needle { found := -1 for candidateIndex < len(candidate) { if candidate[candidateIndex] == wanted { found = candidateIndex candidateIndex++ break } candidateIndex++ } if found < 0 { return 0, false } score += 20 if found == previous+1 { score += 15 } if found == 0 || strings.ContainsRune("/._-", candidate[found-1]) { score += 12 } score -= found previous = found } return score - len(candidate), true } func pad(s string, width int) string { n := width - lipgloss.Width(s) if n <= 0 { return truncate(s, width) } return s + strings.Repeat(" ", n) } func wrap(text string, width int) string { words := strings.Fields(text) if len(words) == 0 { return "" } lines, line := []string{}, words[0] for _, word := range words[1:] { if len([]rune(line))+1+len([]rune(word)) > width { lines, line = append(lines, line), word } else { line += " " + word } } return strings.Join(append(lines, line), "\n") }