Files
diple/tui.go

4483 lines
134 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
type writeMode int
const (
writeNone writeMode = iota
writeReply
writeReplyConfirm
writeReplyBusy
writeResolveConfirm
writeResolveBusy
writePREdit
writePREditConfirm
writePREditBusy
writeAutoMergeConfirm
writeAutoMergeBusy
writeMergeNowConfirm
writeMergeNowBusy
)
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 {
owner string
repo string
branches []RepositoryBranch
err error
}
type repositoryUsersLoadedMsg struct {
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
searchOrigin int
helpVisible bool
helpScroll int
writeMode writeMode
writeThreadID string
replyDraft string
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
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
aiPreview AIPreview
aiCancel context.CancelFunc
aiStatus AIProviderStatus
aiStatusBusy bool
aiPreviewScroll int
aiProgress AIRunProgress
aiSpinner int
aiEvents <-chan tea.Msg
difflet diffletModel
}
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
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,
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) 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.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 {
return "write action unavailable while PR data is refreshing"
}
if m.details.FromCache {
return "write action unavailable from an offline cached snapshot"
}
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 writeReply:
switch k {
case "esc":
m.writeMode, m.replyDraft, m.writeThreadID = writeNone, "", ""
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
}
case "enter":
m.replyDraft += "\n"
case "backspace":
runes := []rune(m.replyDraft)
if len(runes) > 0 {
m.replyDraft = string(runes[:len(runes)-1])
}
m.err = nil
default:
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
m.replyDraft += textInputKeyValue(key)
m.err = nil
}
}
if m.writeMode == writeReply {
m.scroll = m.detailMaxScroll()
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 {
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}
}
}
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 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 = 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, m.difflet.setState(diffletSleeping)
}
return m, m.difflet.setState(diffletIdle)
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)
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)
}
}
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 branchesLoadedMsg:
if 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 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 := ""
if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) {
selected = m.details.Threads[m.threadIndex].ID
}
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.writeThreadID = "", ""
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":
m.searching, m.searchQuery = false, ""
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 "backspace":
runes := []rune(m.searchQuery)
if len(runes) > 0 {
m.searchQuery = string(runes[:len(runes)-1])
m.selectBestSearchMatch()
}
case "ctrl+u":
m.searchQuery = ""
m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1)
m.scroll = 0
default:
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
m.searchQuery += textInputKeyValue(key)
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.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.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)
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
}
for _, thread := range details.Threads {
threadIsNew := !state.Threads[thread.ID]
updated := threadIsNew
if threadIsNew {
m.newThreads[thread.ID] = true
}
for _, comment := range thread.Comments {
if !state.Comments[comment.ID] {
updated = true
m.unreadComments[comment.ID] = true
}
m.knownComments[comment.ID] = true
}
m.knownThreads[thread.ID] = true
if updated {
m.unreadThreads[thread.ID] = true
m.updatedThreads[thread.ID] = true
}
}
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) 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) {
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))
}
gap := diffletGap
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 lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
strings.Join(mascot, "\n") + "\n\n" + content.viewContent(),
)
}
bandHeight := max(diffletHeight, headerLineCount)
addedRows := bandHeight - headerLineCount
if m.width <= diffletWidth {
addedRows = diffletHeight
}
content.height = max(1, m.height-addedRows)
content.contentTop = addedRows
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, headerWidth, gap)
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
headerBand + "\n\n" + body,
)
}
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.replyDraft, width-2, m.cursorOutput != nil,
)...)
if m.err != nil {
lines = append(lines, "", badStyle.Render(m.err.Error()))
}
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
"%s newline • %s review • %s cancel",
primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel),
)))
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…")}
}
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
}
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"},
{combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), "Complete the selected branch or user"},
}
if m.prEditEditors[prEditBodyField].Modal {
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 description"},
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 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 description 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"
}
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) 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.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 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 {
if pr.FromCache {
reason := "offline cached snapshot"
return []writeCapability{
{name: "reply", reason: reason}, {name: "resolve", reason: reason},
{name: "react", reason: reason}, {name: "update pull request", reason: reason},
{name: "auto-merge", reason: reason}, {name: "merge now", reason: reason},
}
}
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"
}
return []writeCapability{
capability("reply", canReply, threadReason, true),
capability(resolveName, canResolve, threadReason, true),
capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission", false),
capability("update pull request", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", true),
capability("auto-merge", autoMergeAllowed, autoMergeReason, true),
capability("merge now", mergeNowStateReason(pr) == "",
firstNonEmpty(mergeNowStateReason(pr), "available"), 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 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 {
help = fmt.Sprintf(
"reply inline • %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 • %s newline • %s prepare • %s cancel",
primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel),
)
}
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 := ansi.Truncate(m.searchQuery, queryWidth, "…")
lines = append(lines, titleStyle.Render("Filter: ")+query+
inputCursorFallback(m.cursorOutput != nil))
}
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 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 {
removed, added := normalizeSuggestion(reviewedSourceLines(thread, comment), suggestion)
removedRanges, addedRanges := suggestionChangedRanges(removed, added)
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)})
for sourceIndex, source := range removed {
lines = append(lines, addCommentRail(
wrapSuggestionLine(
thread.Path, source, '-', removedRanges[sourceIndex], max(1, width-4),
), rail,
)...)
}
for sourceIndex, source := range added {
lines = append(lines, addCommentRail(
wrapSuggestionLine(
thread.Path, source, '+', addedRanges[sourceIndex], max(1, width-4),
), 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.replyDraft, 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())})
}
lines = append(lines, detailLine{
rail: rail,
text: dimStyle.Render(fmt.Sprintf(
"%s newline • %s review • %s cancel",
primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel),
)),
})
return lines
}
func inputCursorFallback(hardwareCursor bool) string {
if hardwareCursor {
return ""
}
return "\x1b[4m \x1b[24m"
}
func renderTextInput(value string, width int, hardwareCursor bool) []string {
const cursorSentinel = "\ue000"
if hardwareCursor {
value += cursorSentinel
} else {
value += inputCursorFallback(false)
}
var lines []string
for _, sourceLine := range strings.Split(value, "\n") {
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, width, ""), width, false)
if hardwareCursor {
wrapped = strings.TrimSuffix(wrapped, cursorSentinel)
}
lines = append(lines, strings.Split(wrapped, "\n")...)
}
return lines
}
func textInputKeyValue(key tea.KeyMsg) string {
if key.Type == tea.KeySpace {
return " "
}
return string(key.Runes)
}
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)
query := ansi.Truncate(m.searchQuery, queryWidth, "…")
m.cursorOutput.SetCursor(
true,
2+ansi.StringWidth("Filter: ")+ansi.StringWidth(query),
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:"
}
cursorLine := -1
for index := range lines {
if strings.HasPrefix(lines[index].anchor, prefix) {
cursorLine = index
}
}
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)+ansi.StringWidth(line.fixed+line.text),
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"))
}
footer := truncate(help, m.width)
if status != "" {
footer = truncate(help, max(0, m.width-lipgloss.Width(status)-2)) + " " + status
}
bodyLines := strings.Split(body, "\n")
if len(bodyLines) > max(0, m.height-1) {
bodyLines = bodyLines[:max(0, m.height-1)]
}
for i := range bodyLines {
bodyLines[i] = ansi.Truncate(bodyLines[i], m.width, "")
}
bodyLines = append(bodyLines, ansi.Truncate(dimStyle.Render(footer), m.width, ""))
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n"))
}
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")
}