Add dashboard / description page

This commit is contained in:
2026-07-28 09:58:49 +02:00
parent 60d64dedb2
commit 43fa047765
9 changed files with 465 additions and 51 deletions

View File

@@ -1,13 +1,14 @@
# gh-threads # gh-threads
A read-only terminal UI for people receiving GitHub pull-request reviews. It A read-only terminal UI for people receiving GitHub pull-request reviews. It
shows open PRs, review threads with highlighted diff hunks and comment authors, shows open PRs and a scrollable PR dashboard with the description, branches,
reviewer/assignee state, and the latest commit's check rollup. Resolved threads review state, checks, people, labels, milestone, activity, change statistics,
start folded. GitHub suggestion blocks are shown as syntax-highlighted and thread totals. The thread viewer includes highlighted diff hunks and comment
remove/add previews. Comments render GitHub Flavored Markdown, including quoted authors. Resolved threads start folded. GitHub suggestion blocks are shown as
replies, inline and fenced code, lists and tasks, links, tables, emphasis, syntax-highlighted remove/add previews. Comments and PR descriptions render
strikethrough, emoji, and GitHub alerts. The current PR is refreshed in the GitHub Flavored Markdown, including quoted replies, inline and fenced code,
background. lists and tasks, links, tables, emphasis, strikethrough, emoji, and GitHub
alerts. The current PR is refreshed in the background.
## Install and run ## Install and run
@@ -76,6 +77,7 @@ endpoint = "https://api.github.com/graphql"
[display] [display]
fold_resolved = true fold_resolved = true
thread_list_width_percent = 33 # 20-60 thread_list_width_percent = 33 # 20-60
dashboard_mode = "hotkey" # "hotkey" or "intermediate"
[paths] [paths]
scroll = false scroll = false
@@ -91,25 +93,32 @@ within_status = "file" # "file" or "timestamp" (oldest first)
Command-line flags override the configuration. `GH_REPO` overrides the Command-line flags override the configuration. `GH_REPO` overrides the
configured repository when `--repo` is not provided. The corresponding flags configured repository when `--repo` is not provided. The corresponding flags
include `--config`, `--theme`, `--poll`, `--fold-resolved`, include `--config`, `--theme`, `--poll`, `--fold-resolved`,
`--thread-list-width`, `--path-scroll`, and `--path-scroll-interval`. Boolean `--thread-list-width`, `--dashboard-mode`, `--path-scroll`, and
settings can be disabled explicitly, for example `--path-scroll=false`. `--path-scroll-interval`. Boolean settings can be disabled explicitly, for
example `--path-scroll=false`.
With the default `dashboard_mode = "hotkey"`, opening a PR goes directly to its
review threads and `d` opens the dashboard only when requested. Set
`dashboard_mode = "intermediate"` to follow picker → dashboard → review
threads instead.
## Keys ## Keys
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
| `h` / `l` | Focus the thread list / thread detail | | `h` / `l` | Focus the thread list / thread detail |
| `j` / `k` | Move between threads or scroll the focused detail | | `j` / `k` | Move between items or scroll the dashboard/focused detail |
| `?` | Show contextual keybinding help | | `?` | Show contextual keybinding help |
| `d` | Open the current pull request dashboard |
| `/` | Fuzzy-search thread file paths | | `/` | Fuzzy-search thread file paths |
| `↑` / `↓` | Choose a fuzzy-search match | | `↑` / `↓` | Choose a fuzzy-search match |
| `g` / `G` | First / last item | | `g` / `G` | First / last item |
| `enter` / `l` | Open a PR | | `enter` / `l` | Open the selected PR dashboard or its review threads |
| `enter` | Toggle the selected review thread | | `enter` | Toggle the selected review thread |
| `za` | Toggle the selected thread | | `za` | Toggle the selected thread |
| `ctrl-d` / `ctrl-u` | Scroll thread detail or page through lists | | `ctrl-d` / `ctrl-u` | Scroll thread detail or page through lists |
| `tab` | Hide or reveal the thread list | | `tab` | Hide or reveal the thread list |
| `b` / `esc` | Return to the PR picker | | `b` / `esc` | Return to the previous screen |
| `r` | Refresh now | | `r` | Refresh now |
| `q` | Quit | | `q` | Quit |

View File

@@ -37,8 +37,9 @@ type Config struct {
} }
type DisplayConfig struct { type DisplayConfig struct {
FoldResolved bool `toml:"fold_resolved"` FoldResolved bool `toml:"fold_resolved"`
ThreadListWidthPercent int `toml:"thread_list_width_percent"` ThreadListWidthPercent int `toml:"thread_list_width_percent"`
DashboardMode string `toml:"dashboard_mode"`
} }
type PathConfig struct { type PathConfig struct {
@@ -60,6 +61,7 @@ func defaultConfig() Config {
Display: DisplayConfig{ Display: DisplayConfig{
FoldResolved: true, FoldResolved: true,
ThreadListWidthPercent: 33, ThreadListWidthPercent: 33,
DashboardMode: "hotkey",
}, },
Paths: PathConfig{ Paths: PathConfig{
Scroll: false, Scroll: false,
@@ -139,6 +141,11 @@ func validateConfig(config Config) error {
if config.Display.ThreadListWidthPercent < 20 || config.Display.ThreadListWidthPercent > 60 { if config.Display.ThreadListWidthPercent < 20 || config.Display.ThreadListWidthPercent > 60 {
return fmt.Errorf("display.thread_list_width_percent must be between 20 and 60") return fmt.Errorf("display.thread_list_width_percent must be between 20 and 60")
} }
switch config.Display.DashboardMode {
case "intermediate", "hotkey":
default:
return fmt.Errorf("display.dashboard_mode must be intermediate or hotkey")
}
if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil { if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil {
return err return err
} }

View File

@@ -36,6 +36,7 @@ endpoint = "https://github.example.com/api/graphql"
[display] [display]
fold_resolved = false fold_resolved = false
thread_list_width_percent = 45 thread_list_width_percent = 45
dashboard_mode = "hotkey"
[paths] [paths]
scroll = true scroll = true
@@ -58,6 +59,7 @@ within_status = "timestamp"
if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second || if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second ||
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 || got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 || got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
got.Display.DashboardMode != "hotkey" ||
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond || !got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" || strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
got.Threads.WithinStatus != "timestamp" { got.Threads.WithinStatus != "timestamp" {
@@ -145,3 +147,11 @@ func TestValidateConfigRejectsInvalidThreadOrdering(t *testing.T) {
t.Fatal("unknown within-status ordering was accepted") t.Fatal("unknown within-status ordering was accepted")
} }
} }
func TestValidateConfigRejectsInvalidDashboardMode(t *testing.T) {
config := defaultConfig()
config.Display.DashboardMode = "sometimes"
if err := validateConfig(config); err == nil {
t.Fatal("unknown dashboard mode was accepted")
}
}

View File

@@ -182,10 +182,15 @@ const detailsQuery = `
query PullRequestDetails($owner: String!, $name: String!, $number: Int!) { query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) { repository(owner: $owner, name: $name) {
pullRequest(number: $number) { pullRequest(number: $number) {
id number title url body isDraft updatedAt mergeable reviewDecision id number title url body isDraft createdAt updatedAt
mergeable mergeStateStatus reviewDecision
baseRefName headRefName baseRefName headRefName
author { login } author { login }
assignees(first: 20) { nodes { login } } assignees(first: 20) { nodes { login } }
labels(first: 20) { nodes { name } }
milestone { title }
additions deletions changedFiles
comments(first: 1) { totalCount }
reviewRequests(first: 50) { reviewRequests(first: 50) {
nodes { nodes {
requestedReviewer { requestedReviewer {
@@ -196,6 +201,7 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
} }
latestReviews(first: 50) { nodes { state author { login } } } latestReviews(first: 50) { nodes { state author { login } } }
commits(last: 1) { commits(last: 1) {
totalCount
nodes { commit { statusCheckRollup { state } } } nodes { commit { statusCheckRollup { state } } }
} }
reviewThreads(first: 100) { reviewThreads(first: 100) {
@@ -227,14 +233,26 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
var data struct { var data struct {
Repository *struct { Repository *struct {
PullRequest *struct { PullRequest *struct {
ID, Title, URL, Body, Mergeable, ReviewDecision, BaseRefName, HeadRefName string ID, Title, URL, Body, Mergeable, MergeStateStatus string
Number int ReviewDecision, BaseRefName, HeadRefName string
IsDraft bool Number, Additions, Deletions, ChangedFiles int
UpdatedAt time.Time IsDraft bool
Author *actor CreatedAt, UpdatedAt time.Time
Assignees struct { Author *actor
Assignees struct {
Nodes []actor `json:"nodes"` Nodes []actor `json:"nodes"`
} }
Labels struct {
Nodes []struct {
Name string `json:"name"`
} `json:"nodes"`
}
Milestone *struct {
Title string `json:"title"`
}
Comments struct {
TotalCount int `json:"totalCount"`
}
ReviewRequests struct { ReviewRequests struct {
Nodes []struct { Nodes []struct {
RequestedReviewer actor `json:"requestedReviewer"` RequestedReviewer actor `json:"requestedReviewer"`
@@ -247,7 +265,8 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
} `json:"nodes"` } `json:"nodes"`
} }
Commits struct { Commits struct {
Nodes []struct { TotalCount int `json:"totalCount"`
Nodes []struct {
Commit struct { Commit struct {
StatusCheckRollup *struct{ State string } StatusCheckRollup *struct{ State string }
} }
@@ -295,9 +314,18 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt, Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt,
ReviewCount: len(node.ReviewThreads.Nodes), ReviewCount: len(node.ReviewThreads.Nodes),
}, },
Body: node.Body, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName, Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
Mergeable: node.Mergeable, ThreadsTruncated: node.ReviewThreads.PageInfo.HasNextPage, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
CheckState: "NONE", ReviewDecision: node.ReviewDecision, Additions: node.Additions, Deletions: node.Deletions, ChangedFiles: node.ChangedFiles,
CommitCount: node.Commits.TotalCount, CommentCount: node.Comments.TotalCount,
ThreadsTruncated: node.ReviewThreads.PageInfo.HasNextPage,
CheckState: "NONE", ReviewDecision: node.ReviewDecision,
}
for _, label := range node.Labels.Nodes {
details.Labels = append(details.Labels, label.Name)
}
if node.Milestone != nil {
details.Milestone = node.Milestone.Title
} }
for _, assignee := range node.Assignees.Nodes { for _, assignee := range node.Assignees.Nodes {
details.Assignees = append(details.Assignees, assignee.Login) details.Assignees = append(details.Assignees, assignee.Login)

View File

@@ -89,12 +89,17 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{ _, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{
"id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false, "id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false,
"updatedAt":"2026-01-01T00:00:00Z","mergeable":"MERGEABLE","reviewDecision":"APPROVED", "createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z",
"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED",
"additions":12,"deletions":4,"changedFiles":3,
"baseRefName":"main","headRefName":"fix","author":{"login":"zam"}, "baseRefName":"main","headRefName":"fix","author":{"login":"zam"},
"assignees":{"nodes":[{"login":"sam"}]}, "assignees":{"nodes":[{"login":"sam"}]},
"labels":{"nodes":[{"name":"bug"},{"name":"backend"}]},
"milestone":{"title":"v2"},
"comments":{"totalCount":5},
"reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]}, "reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]},
"latestReviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"pat"}}]}, "latestReviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"pat"}}]},
"commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"FAILURE"}}}]}, "commits":{"totalCount":7,"nodes":[{"commit":{"statusCheckRollup":{"state":"FAILURE"}}}]},
"reviewThreads":{"pageInfo":{"hasNextPage":false},"nodes":[{ "reviewThreads":{"pageInfo":{"hasNextPage":false},"nodes":[{
"id":"t","isResolved":false,"isOutdated":true,"path":"main.go", "id":"t","isResolved":false,"isOutdated":true,"path":"main.go",
"line":null,"originalLine":42,"diffSide":"RIGHT", "line":null,"originalLine":42,"diffSide":"RIGHT",
@@ -118,6 +123,12 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
if got.CheckState != "FAILURE" || got.BaseRef != "main" || got.HeadRef != "fix" || got.ReviewDecision != "APPROVED" { if got.CheckState != "FAILURE" || got.BaseRef != "main" || got.HeadRef != "fix" || got.ReviewDecision != "APPROVED" {
t.Fatalf("unexpected metadata: %#v", got) t.Fatalf("unexpected metadata: %#v", got)
} }
if got.MergeState != "CLEAN" || got.Additions != 12 || got.Deletions != 4 ||
got.ChangedFiles != 3 || got.CommitCount != 7 || got.CommentCount != 5 ||
got.Milestone != "v2" || strings.Join(got.Labels, ",") != "bug,backend" ||
got.CreatedAt.IsZero() {
t.Fatalf("unexpected dashboard metadata: %#v", got)
}
if len(got.Threads) != 1 || got.Threads[0].Line != 42 || got.Threads[0].StartLine != 40 || if len(got.Threads) != 1 || got.Threads[0].Line != 42 || got.Threads[0].StartLine != 40 ||
got.Threads[0].DiffSide != "RIGHT" || !got.Threads[0].IsTruncated { got.Threads[0].DiffSide != "RIGHT" || !got.Threads[0].IsTruncated {
t.Fatalf("unexpected thread: %#v", got.Threads) t.Fatalf("unexpected thread: %#v", got.Threads)

View File

@@ -25,6 +25,7 @@ func main() {
theme = flag.String("theme", defaults.Theme, "color theme: dark or light") theme = flag.String("theme", defaults.Theme, "color theme: dark or light")
foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved threads folded") foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved threads folded")
listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread list width as terminal percentage (20-60)") listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread list width as terminal percentage (20-60)")
dashboardMode = flag.String("dashboard-mode", defaults.Display.DashboardMode, "dashboard navigation: intermediate or hotkey")
pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths") pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths")
pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval") pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval")
) )
@@ -62,6 +63,9 @@ func main() {
if visited["thread-list-width"] { if visited["thread-list-width"] {
config.Display.ThreadListWidthPercent = *listWidth config.Display.ThreadListWidthPercent = *listWidth
} }
if visited["dashboard-mode"] {
config.Display.DashboardMode = *dashboardMode
}
if visited["path-scroll"] { if visited["path-scroll"] {
config.Paths.Scroll = *pathScroll config.Paths.Scroll = *pathScroll
} }
@@ -94,6 +98,7 @@ func main() {
AppSettings{ AppSettings{
FoldResolved: config.Display.FoldResolved, FoldResolved: config.Display.FoldResolved,
ThreadListWidthPercent: config.Display.ThreadListWidthPercent, ThreadListWidthPercent: config.Display.ThreadListWidthPercent,
DashboardMode: config.Display.DashboardMode,
PathScroll: config.Paths.Scroll, PathScroll: config.Paths.Scroll,
PathScrollInterval: config.Paths.ScrollInterval.Duration, PathScrollInterval: config.Paths.ScrollInterval.Duration,
ThreadStatusOrder: config.Threads.StatusOrder, ThreadStatusOrder: config.Threads.StatusOrder,

245
tui.go
View File

@@ -17,6 +17,7 @@ type screen int
const ( const (
prScreen screen = iota prScreen screen = iota
dashboardScreen
threadScreen threadScreen
) )
@@ -75,6 +76,8 @@ type App struct {
pathScrollStep int pathScrollStep int
threadStatusOrder []string threadStatusOrder []string
threadWithinStatus string threadWithinStatus string
dashboardMode string
dashboardReturn screen
} }
type AppSettings struct { type AppSettings struct {
@@ -84,6 +87,7 @@ type AppSettings struct {
PathScrollInterval time.Duration PathScrollInterval time.Duration
ThreadStatusOrder []string ThreadStatusOrder []string
ThreadWithinStatus string ThreadWithinStatus string
DashboardMode string
} }
func defaultAppSettings() AppSettings { func defaultAppSettings() AppSettings {
@@ -93,6 +97,7 @@ func defaultAppSettings() AppSettings {
PathScrollInterval: 350 * time.Millisecond, PathScrollInterval: 350 * time.Millisecond,
ThreadStatusOrder: []string{"unresolved", "outdated", "resolved"}, ThreadStatusOrder: []string{"unresolved", "outdated", "resolved"},
ThreadWithinStatus: "file", ThreadWithinStatus: "file",
DashboardMode: "hotkey",
} }
} }
@@ -115,6 +120,7 @@ func NewAppWithSettings(
pathScroll: settings.PathScroll, pathScrollInterval: settings.PathScrollInterval, pathScroll: settings.PathScroll, pathScrollInterval: settings.PathScrollInterval,
threadStatusOrder: append([]string(nil), settings.ThreadStatusOrder...), threadStatusOrder: append([]string(nil), settings.ThreadStatusOrder...),
threadWithinStatus: settings.ThreadWithinStatus, threadWithinStatus: settings.ThreadWithinStatus,
dashboardMode: settings.DashboardMode, dashboardReturn: prScreen,
} }
} }
@@ -162,7 +168,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tickMsg: case tickMsg:
if !m.loading { if !m.loading {
m.loading = true m.loading = true
if m.screen == threadScreen && m.details.Number != 0 { if (m.screen == dashboardScreen || m.screen == threadScreen) && m.details.Number != 0 {
return m, tea.Batch(m.loadDetails(m.details.PullRequest), m.nextTick()) return m, tea.Batch(m.loadDetails(m.details.PullRequest), m.nextTick())
} }
return m, tea.Batch(m.loadPRs(), m.nextTick()) return m, tea.Batch(m.loadPRs(), m.nextTick())
@@ -222,7 +228,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.folded[thread.ID] = true m.folded[thread.ID] = true
} }
} }
m.scroll = min(m.scroll, m.detailMaxScroll()) if m.screen == dashboardScreen {
m.scroll = min(m.scroll, m.dashboardMaxScroll())
} else {
m.scroll = min(m.scroll, m.detailMaxScroll())
}
m.err = nil m.err = nil
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
} }
@@ -316,18 +326,22 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.loading = true m.loading = true
if m.screen == threadScreen { if m.screen == dashboardScreen || m.screen == threadScreen {
return m, m.loadDetails(m.details.PullRequest) return m, m.loadDetails(m.details.PullRequest)
} }
return m, m.loadPRs() return m, m.loadPRs()
case "j", "down": case "j", "down":
if m.screen == threadScreen && m.focus == threadDetailPane { if m.screen == dashboardScreen {
m.scrollDashboard(1)
} else if m.screen == threadScreen && m.focus == threadDetailPane {
m.scrollDetail(1) m.scrollDetail(1)
} else { } else {
m.move(1) m.move(1)
} }
case "k", "up": case "k", "up":
if m.screen == threadScreen && m.focus == threadDetailPane { if m.screen == dashboardScreen {
m.scrollDashboard(-1)
} else if m.screen == threadScreen && m.focus == threadDetailPane {
m.scrollDetail(-1) m.scrollDetail(-1)
} else { } else {
m.move(-1) m.move(-1)
@@ -350,14 +364,21 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.page(1) m.page(1)
case "ctrl+u", "pgup": case "ctrl+u", "pgup":
m.page(-1) m.page(-1)
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 "l": case "l":
if m.screen == prScreen && len(m.prs) > 0 { if m.screen == prScreen && len(m.prs) > 0 {
m.screen = threadScreen return m, m.openSelectedPR(m.defaultPRTargetScreen())
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 if m.screen == dashboardScreen {
m.searching, m.searchQuery = false, "" m.screen, m.scroll, m.focus, m.listHidden = threadScreen, 0, threadListPane, false
m.pathScrollStep = 0 return m, nil
return m, m.loadDetails(m.details.PullRequest)
} }
if m.screen == threadScreen { if m.screen == threadScreen {
m.focus = threadDetailPane m.focus = threadDetailPane
@@ -369,12 +390,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case "enter": case "enter":
if m.screen == prScreen && len(m.prs) > 0 { if m.screen == prScreen && len(m.prs) > 0 {
m.screen = threadScreen return m, m.openSelectedPR(m.defaultPRTargetScreen())
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 if m.screen == dashboardScreen {
m.searching, m.searchQuery = false, "" m.screen, m.scroll, m.focus, m.listHidden = threadScreen, 0, threadListPane, false
m.pathScrollStep = 0 return m, nil
return m, m.loadDetails(m.details.PullRequest)
} }
if m.screen == threadScreen && len(m.details.Threads) > 0 { if m.screen == threadScreen && len(m.details.Threads) > 0 {
thread := m.details.Threads[m.threadIndex] thread := m.details.Threads[m.threadIndex]
@@ -383,14 +403,42 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case "b", "esc": case "b", "esc":
if m.screen == threadScreen { 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 m.screen, m.err, m.loading = prScreen, nil, true
m.searching, m.searchQuery = false, ""
return m, m.loadPRs() return m, m.loadPRs()
} }
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, m.loadPRs()
}
return m, nil
}
} }
return m, nil return m, nil
} }
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 m.loadDetails(m.details.PullRequest)
}
func (m *App) move(delta int) { func (m *App) move(delta int) {
if m.screen == prScreen { if m.screen == prScreen {
m.prIndex = clamp(m.prIndex+delta, 0, len(m.prs)-1) m.prIndex = clamp(m.prIndex+delta, 0, len(m.prs)-1)
@@ -532,6 +580,8 @@ func compareThreadTimestamps(left, right ReviewThread) (less, decided bool) {
func (m *App) toStart() { func (m *App) toStart() {
if m.screen == prScreen { if m.screen == prScreen {
m.prIndex = 0 m.prIndex = 0
} else if m.screen == dashboardScreen {
m.scroll = 0
} else if m.focus == threadDetailPane { } else if m.focus == threadDetailPane {
m.scroll = 0 m.scroll = 0
} else { } else {
@@ -541,6 +591,8 @@ func (m *App) toStart() {
func (m *App) toEnd() { func (m *App) toEnd() {
if m.screen == prScreen { if m.screen == prScreen {
m.prIndex = max(0, len(m.prs)-1) m.prIndex = max(0, len(m.prs)-1)
} else if m.screen == dashboardScreen {
m.scroll = m.dashboardMaxScroll()
} else if m.focus == threadDetailPane { } else if m.focus == threadDetailPane {
m.scroll = m.detailMaxScroll() m.scroll = m.detailMaxScroll()
} else { } else {
@@ -548,6 +600,10 @@ func (m *App) toEnd() {
} }
} }
func (m *App) page(direction int) { func (m *App) page(direction int) {
if m.screen == dashboardScreen {
m.scrollDashboard(direction * max(3, m.dashboardViewportHeight()/2))
return
}
if m.screen == threadScreen && m.focus == threadDetailPane { if m.screen == threadScreen && m.focus == threadDetailPane {
m.scrollDetail(direction * max(3, m.detailViewportHeight()/2)) m.scrollDetail(direction * max(3, m.detailViewportHeight()/2))
return return
@@ -559,6 +615,18 @@ func (m *App) scrollDetail(delta int) {
m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll()) m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll())
} }
func (m *App) scrollDashboard(delta int) {
m.scroll = clamp(m.scroll+delta, 0, m.dashboardMaxScroll())
}
func (m App) dashboardViewportHeight() int {
return max(1, m.height-2)
}
func (m App) dashboardMaxScroll() int {
return max(0, len(m.dashboardLines())-m.dashboardViewportHeight())
}
func (m App) detailPaneSize() (int, int) { func (m App) detailPaneSize() (int, int) {
topLines := 3 topLines := 3
if m.details.ThreadsTruncated { if m.details.ThreadsTruncated {
@@ -600,6 +668,9 @@ func (m App) View() string {
if m.screen == prScreen { if m.screen == prScreen {
return m.viewPRs() return m.viewPRs()
} }
if m.screen == dashboardScreen {
return m.viewDashboard()
}
return m.viewThreads() return m.viewThreads()
} }
@@ -610,17 +681,43 @@ type helpBinding struct {
func (m App) helpBindings() []helpBinding { func (m App) helpBindings() []helpBinding {
if m.screen == prScreen { if m.screen == prScreen {
openAction := "Open pull request dashboard"
if m.dashboardMode == "hotkey" {
openAction = "Open review threads"
}
return []helpBinding{ return []helpBinding{
{"j / ↓", "Next pull request"}, {"j / ↓", "Next pull request"},
{"k / ↑", "Previous pull request"}, {"k / ↑", "Previous pull request"},
{"g / G", "First / last pull request"}, {"g / G", "First / last pull request"},
{"ctrl-d / ctrl-u", "Page down / up"}, {"ctrl-d / ctrl-u", "Page down / up"},
{"enter / l", "Open pull request"}, {"enter / l", openAction},
{"d", "Open pull request dashboard"},
{"r", "Refresh now"}, {"r", "Refresh now"},
{"?", "Close this help"}, {"?", "Close this help"},
{"q / ctrl-c", "Quit"}, {"q / ctrl-c", "Quit"},
} }
} }
if m.screen == dashboardScreen {
backAction := "Return to pull requests"
if m.dashboardReturn == threadScreen {
backAction = "Return to review threads"
}
return []helpBinding{
{"j / ↓", "Scroll description down"},
{"k / ↑", "Scroll description up"},
{"g / G", "Top / bottom"},
{"ctrl-d / ctrl-u", "Page down / up"},
{"enter / l", "Open review threads"},
{"b / esc", backAction},
{"r", "Refresh now"},
{"?", "Close this help"},
{"q / ctrl-c", "Quit"},
}
}
backAction := "Return to pull requests"
if m.dashboardMode == "intermediate" {
backAction = "Return to PR dashboard"
}
return []helpBinding{ return []helpBinding{
{"h / l", "Focus thread list / detail"}, {"h / l", "Focus thread list / detail"},
{"j / k", "Move or scroll focused pane"}, {"j / k", "Move or scroll focused pane"},
@@ -629,8 +726,9 @@ func (m App) helpBindings() []helpBinding {
{"ctrl-d / ctrl-u", "Page down / up"}, {"ctrl-d / ctrl-u", "Page down / up"},
{"tab", "Hide / reveal thread list"}, {"tab", "Hide / reveal thread list"},
{"/", "Fuzzy-search file paths"}, {"/", "Fuzzy-search file paths"},
{"d", "Open pull request dashboard"},
{"enter / za", "Fold / expand thread"}, {"enter / za", "Fold / expand thread"},
{"b / esc", "Return to pull requests"}, {"b / esc", backAction},
{"r", "Refresh now"}, {"r", "Refresh now"},
{"?", "Close this help"}, {"?", "Close this help"},
{"q / ctrl-c", "Quit"}, {"q / ctrl-c", "Quit"},
@@ -654,7 +752,9 @@ func (m App) viewHelp() string {
keyWidth := min(17, max(8, contentWidth/3)) keyWidth := min(17, max(8, contentWidth/3))
title := "Pull request picker keys" title := "Pull request picker keys"
if m.screen == threadScreen { if m.screen == dashboardScreen {
title = "Pull request dashboard keys"
} else if m.screen == threadScreen {
title = "Review thread keys" title = "Review thread keys"
} }
lines := []string{titleStyle.Render(title)} lines := []string{titleStyle.Render(title)}
@@ -739,7 +839,11 @@ func (m App) viewPRs() string {
} }
lines = append(lines, line) lines = append(lines, line)
} }
return m.frame(lines, "? keys • j/k move • enter open • q quit") footer := "? keys • j/k move • enter dashboard • q quit"
if m.dashboardMode == "hotkey" {
footer = "? keys • j/k move • enter threads • d dashboard • q quit"
}
return m.frame(lines, footer)
} }
type prListRow struct { type prListRow struct {
@@ -769,6 +873,101 @@ func groupedPRRows(prs []PullRequest, selected int) ([]prListRow, int) {
return rows, selectedRow return rows, selectedRow
} }
func (m App) viewDashboard() string {
lines := m.dashboardLines()
viewportHeight := m.dashboardViewportHeight()
maxScroll := max(0, len(lines)-viewportHeight)
scroll := min(m.scroll, maxScroll)
visible := lines[scroll:min(len(lines), scroll+viewportHeight)]
return m.frame(visible, "? keys • j/k scroll • enter threads • b back • q quit")
}
func (m App) dashboardLines() []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 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", authorStyle(pr.Author).Render("@"+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("assignees", handlesText(pr.Assignees)),
dashboardMetadata("reviewers", 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("created", created),
dashboardMetadata("updated", updated),
dashboardMetadata("url", pr.URL),
)
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)...)
}
return lines
}
func dashboardMetadata(label, value string) string {
return titleStyle.Render(pad(label+":", 13)) + " " + value
}
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 (m App) viewThreads() string { func (m App) viewThreads() string {
pr := m.details pr := m.details
header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, m.width-len(pr.RepoWithOwner)-12)))) header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, m.width-len(pr.RepoWithOwner)-12))))
@@ -796,7 +995,7 @@ func (m App) viewThreads() string {
right := m.threadDetail(rightWidth, contentHeight) right := m.threadDetail(rightWidth, contentHeight)
body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
} }
help := "? keys • h/l focus • j/k move/scroll • b back • q quit" help := "? keys • h/l focus • j/k move/scroll • d dashboard • b back • q quit"
if m.searching { if m.searching {
help = "type to fuzzy search • ↑/↓ choose • enter jump • esc cancel" help = "type to fuzzy search • ↑/↓ choose • enter jump • esc cancel"
} }

View File

@@ -166,6 +166,142 @@ func TestPaneFocusAndNavigation(t *testing.T) {
} }
} }
func TestOpeningPullRequestShowsDashboardBeforeThreads(t *testing.T) {
settings := defaultAppSettings()
settings.DashboardMode = "intermediate"
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.prs = []PullRequest{{
ID: "pr", Owner: "owner", Repository: "repo", RepoWithOwner: "owner/repo", Number: 9,
}}
updated, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(App)
if m.screen != dashboardScreen || m.details.Number != 9 || command == nil {
t.Fatalf("opening PR produced screen=%d number=%d command=%v", m.screen, m.details.Number, command)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(App)
if m.screen != threadScreen {
t.Fatal("enter did not open review threads from dashboard")
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")})
m = updated.(App)
if m.screen != dashboardScreen {
t.Fatal("b did not return from threads to dashboard")
}
}
func TestDashboardHotkeyModeIsDefault(t *testing.T) {
if got := defaultAppSettings().DashboardMode; got != "hotkey" {
t.Fatalf("default dashboard mode = %q, want hotkey", got)
}
if got := defaultConfig().Display.DashboardMode; got != "hotkey" {
t.Fatalf("default config dashboard mode = %q, want hotkey", got)
}
}
func TestHotkeyDashboardModeOpensThreadsDirectly(t *testing.T) {
settings := defaultAppSettings()
settings.DashboardMode = "hotkey"
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.prs = []PullRequest{{
ID: "pr", Owner: "owner", Repository: "repo", RepoWithOwner: "owner/repo", Number: 9,
}}
updated, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(App)
if m.screen != threadScreen || command == nil {
t.Fatalf("hotkey mode opened screen=%d command=%v", m.screen, command)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")})
m = updated.(App)
if m.screen != dashboardScreen || m.dashboardReturn != threadScreen {
t.Fatal("d did not open a dashboard that returns to threads")
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")})
m = updated.(App)
if m.screen != threadScreen {
t.Fatal("dashboard did not return to the invoking thread screen")
}
}
func TestDashboardHotkeyWorksFromPicker(t *testing.T) {
settings := defaultAppSettings()
settings.DashboardMode = "hotkey"
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.prs = []PullRequest{{
ID: "pr", Owner: "owner", Repository: "repo", RepoWithOwner: "owner/repo", Number: 9,
}}
updated, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")})
m = updated.(App)
if m.screen != dashboardScreen || m.dashboardReturn != prScreen || command == nil {
t.Fatalf("picker dashboard hotkey produced screen=%d return=%d", m.screen, m.dashboardReturn)
}
}
func TestDashboardRendersDescriptionAndMetadata(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = dashboardScreen
m.width, m.height = 100, 40
m.details = PRDetails{
PullRequest: PullRequest{
RepoWithOwner: "owner/repo", Number: 9, Title: "Improve dashboard",
Author: "alice", URL: "https://github.com/owner/repo/pull/9",
UpdatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC),
},
Body: "> Existing behavior\n\nUse `new_behavior` instead.",
CreatedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
BaseRef: "main",
HeadRef: "feature",
ReviewDecision: "REVIEW_REQUIRED",
CheckState: "SUCCESS",
MergeState: "CLEAN",
Assignees: []string{"bob"},
Reviewers: []Reviewer{{Login: "carol", State: "APPROVED"}},
Labels: []string{"ui", "review"},
Milestone: "v2",
Additions: 20,
Deletions: 4,
ChangedFiles: 3,
CommitCount: 2,
CommentCount: 5,
Threads: []ReviewThread{
{ID: "open"},
{ID: "old", IsOutdated: true},
{ID: "done", IsResolved: true, IsOutdated: true},
},
}
plain := ansi.Strip(m.View())
for _, wanted := range []string{
"Improve dashboard", "@alice", "feature → main", "@bob", "@carol",
"ui, review", "v2", "+20", "-4", "3 files", "2 commits",
"1 open", "1 outdated", "1 resolved", "Description",
"Existing behavior", "new_behavior",
} {
if !strings.Contains(plain, wanted) {
t.Fatalf("dashboard is missing %q:\n%s", wanted, plain)
}
}
}
func TestDashboardDescriptionScrolls(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = dashboardScreen
m.width, m.height = 60, 10
m.details = PRDetails{
PullRequest: PullRequest{RepoWithOwner: "owner/repo", Number: 1, Title: "Long", Author: "alice"},
BaseRef: "main",
HeadRef: "feature",
Body: strings.Repeat("A long description line.\n\n", 20),
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")})
m = updated.(App)
if m.scroll == 0 || m.scroll != m.dashboardMaxScroll() {
t.Fatalf("dashboard scroll = %d, max = %d", m.scroll, m.dashboardMaxScroll())
}
}
func TestContextualHelpOpensAndClosesWithoutLeavingScreen(t *testing.T) { func TestContextualHelpOpensAndClosesWithoutLeavingScreen(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen m.screen = threadScreen
@@ -179,7 +315,7 @@ func TestContextualHelpOpensAndClosesWithoutLeavingScreen(t *testing.T) {
plain := ansi.Strip(m.View()) plain := ansi.Strip(m.View())
if !strings.Contains(plain, "Review thread keys") || if !strings.Contains(plain, "Review thread keys") ||
!strings.Contains(plain, "Fuzzy-search file paths") || !strings.Contains(plain, "Fuzzy-search file paths") ||
strings.Contains(plain, "Open pull request") { strings.Contains(plain, "Next pull request") {
t.Fatalf("help was not contextual:\n%s", plain) t.Fatalf("help was not contextual:\n%s", plain)
} }

View File

@@ -20,11 +20,20 @@ type PullRequest struct {
type PRDetails struct { type PRDetails struct {
PullRequest PullRequest
Body string Body string
CreatedAt time.Time
BaseRef string BaseRef string
HeadRef string HeadRef string
Mergeable string Mergeable string
MergeState string
Assignees []string Assignees []string
Reviewers []Reviewer Reviewers []Reviewer
Labels []string
Milestone string
Additions int
Deletions int
ChangedFiles int
CommitCount int
CommentCount int
CheckState string CheckState string
ReviewDecision string ReviewDecision string
Threads []ReviewThread Threads []ReviewThread