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
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,
reviewer/assignee state, and the latest commit's check rollup. Resolved threads
start folded. GitHub suggestion blocks are shown as syntax-highlighted
remove/add previews. Comments render GitHub Flavored Markdown, including quoted
replies, inline and fenced code, lists and tasks, links, tables, emphasis,
strikethrough, emoji, and GitHub alerts. The current PR is refreshed in the
background.
shows open PRs and a scrollable PR dashboard with the description, branches,
review state, checks, people, labels, milestone, activity, change statistics,
and thread totals. The thread viewer includes highlighted diff hunks and comment
authors. Resolved threads start folded. GitHub suggestion blocks are shown as
syntax-highlighted remove/add previews. Comments and PR descriptions render
GitHub Flavored Markdown, including quoted replies, inline and fenced code,
lists and tasks, links, tables, emphasis, strikethrough, emoji, and GitHub
alerts. The current PR is refreshed in the background.
## Install and run
@@ -76,6 +77,7 @@ endpoint = "https://api.github.com/graphql"
[display]
fold_resolved = true
thread_list_width_percent = 33 # 20-60
dashboard_mode = "hotkey" # "hotkey" or "intermediate"
[paths]
scroll = false
@@ -91,25 +93,32 @@ within_status = "file" # "file" or "timestamp" (oldest first)
Command-line flags override the configuration. `GH_REPO` overrides the
configured repository when `--repo` is not provided. The corresponding flags
include `--config`, `--theme`, `--poll`, `--fold-resolved`,
`--thread-list-width`, `--path-scroll`, and `--path-scroll-interval`. Boolean
settings can be disabled explicitly, for example `--path-scroll=false`.
`--thread-list-width`, `--dashboard-mode`, `--path-scroll`, and
`--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
| Key | Action |
| --- | --- |
| `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 |
| `d` | Open the current pull request dashboard |
| `/` | Fuzzy-search thread file paths |
| `↑` / `↓` | Choose a fuzzy-search match |
| `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 |
| `za` | Toggle the selected thread |
| `ctrl-d` / `ctrl-u` | Scroll thread detail or page through lists |
| `tab` | Hide or reveal the thread list |
| `b` / `esc` | Return to the PR picker |
| `b` / `esc` | Return to the previous screen |
| `r` | Refresh now |
| `q` | Quit |

View File

@@ -39,6 +39,7 @@ type Config struct {
type DisplayConfig struct {
FoldResolved bool `toml:"fold_resolved"`
ThreadListWidthPercent int `toml:"thread_list_width_percent"`
DashboardMode string `toml:"dashboard_mode"`
}
type PathConfig struct {
@@ -60,6 +61,7 @@ func defaultConfig() Config {
Display: DisplayConfig{
FoldResolved: true,
ThreadListWidthPercent: 33,
DashboardMode: "hotkey",
},
Paths: PathConfig{
Scroll: false,
@@ -139,6 +141,11 @@ func validateConfig(config Config) error {
if config.Display.ThreadListWidthPercent < 20 || config.Display.ThreadListWidthPercent > 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 {
return err
}

View File

@@ -36,6 +36,7 @@ endpoint = "https://github.example.com/api/graphql"
[display]
fold_resolved = false
thread_list_width_percent = 45
dashboard_mode = "hotkey"
[paths]
scroll = true
@@ -58,6 +59,7 @@ within_status = "timestamp"
if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second ||
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
got.Display.DashboardMode != "hotkey" ||
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
got.Threads.WithinStatus != "timestamp" {
@@ -145,3 +147,11 @@ func TestValidateConfigRejectsInvalidThreadOrdering(t *testing.T) {
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!) {
repository(owner: $owner, name: $name) {
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
author { login }
assignees(first: 20) { nodes { login } }
labels(first: 20) { nodes { name } }
milestone { title }
additions deletions changedFiles
comments(first: 1) { totalCount }
reviewRequests(first: 50) {
nodes {
requestedReviewer {
@@ -196,6 +201,7 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
}
latestReviews(first: 50) { nodes { state author { login } } }
commits(last: 1) {
totalCount
nodes { commit { statusCheckRollup { state } } }
}
reviewThreads(first: 100) {
@@ -227,14 +233,26 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
var data struct {
Repository *struct {
PullRequest *struct {
ID, Title, URL, Body, Mergeable, ReviewDecision, BaseRefName, HeadRefName string
Number int
ID, Title, URL, Body, Mergeable, MergeStateStatus string
ReviewDecision, BaseRefName, HeadRefName string
Number, Additions, Deletions, ChangedFiles int
IsDraft bool
UpdatedAt time.Time
CreatedAt, UpdatedAt time.Time
Author *actor
Assignees struct {
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 {
Nodes []struct {
RequestedReviewer actor `json:"requestedReviewer"`
@@ -247,6 +265,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
} `json:"nodes"`
}
Commits struct {
TotalCount int `json:"totalCount"`
Nodes []struct {
Commit struct {
StatusCheckRollup *struct{ State string }
@@ -295,10 +314,19 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt,
ReviewCount: len(node.ReviewThreads.Nodes),
},
Body: node.Body, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
Mergeable: node.Mergeable, ThreadsTruncated: node.ReviewThreads.PageInfo.HasNextPage,
Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
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 {
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) {
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{
"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"},
"assignees":{"nodes":[{"login":"sam"}]},
"labels":{"nodes":[{"name":"bug"},{"name":"backend"}]},
"milestone":{"title":"v2"},
"comments":{"totalCount":5},
"reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]},
"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":[{
"id":"t","isResolved":false,"isOutdated":true,"path":"main.go",
"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" {
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 ||
got.Threads[0].DiffSide != "RIGHT" || !got.Threads[0].IsTruncated {
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")
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)")
dashboardMode = flag.String("dashboard-mode", defaults.Display.DashboardMode, "dashboard navigation: intermediate or hotkey")
pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths")
pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval")
)
@@ -62,6 +63,9 @@ func main() {
if visited["thread-list-width"] {
config.Display.ThreadListWidthPercent = *listWidth
}
if visited["dashboard-mode"] {
config.Display.DashboardMode = *dashboardMode
}
if visited["path-scroll"] {
config.Paths.Scroll = *pathScroll
}
@@ -94,6 +98,7 @@ func main() {
AppSettings{
FoldResolved: config.Display.FoldResolved,
ThreadListWidthPercent: config.Display.ThreadListWidthPercent,
DashboardMode: config.Display.DashboardMode,
PathScroll: config.Paths.Scroll,
PathScrollInterval: config.Paths.ScrollInterval.Duration,
ThreadStatusOrder: config.Threads.StatusOrder,

241
tui.go
View File

@@ -17,6 +17,7 @@ type screen int
const (
prScreen screen = iota
dashboardScreen
threadScreen
)
@@ -75,6 +76,8 @@ type App struct {
pathScrollStep int
threadStatusOrder []string
threadWithinStatus string
dashboardMode string
dashboardReturn screen
}
type AppSettings struct {
@@ -84,6 +87,7 @@ type AppSettings struct {
PathScrollInterval time.Duration
ThreadStatusOrder []string
ThreadWithinStatus string
DashboardMode string
}
func defaultAppSettings() AppSettings {
@@ -93,6 +97,7 @@ func defaultAppSettings() AppSettings {
PathScrollInterval: 350 * time.Millisecond,
ThreadStatusOrder: []string{"unresolved", "outdated", "resolved"},
ThreadWithinStatus: "file",
DashboardMode: "hotkey",
}
}
@@ -115,6 +120,7 @@ func NewAppWithSettings(
pathScroll: settings.PathScroll, pathScrollInterval: settings.PathScrollInterval,
threadStatusOrder: append([]string(nil), settings.ThreadStatusOrder...),
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:
if !m.loading {
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.loadPRs(), m.nextTick())
@@ -222,7 +228,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.folded[thread.ID] = true
}
}
if m.screen == dashboardScreen {
m.scroll = min(m.scroll, m.dashboardMaxScroll())
} else {
m.scroll = min(m.scroll, m.detailMaxScroll())
}
m.err = nil
m.lastRefresh = time.Now()
}
@@ -316,18 +326,22 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.loading = true
if m.screen == threadScreen {
if m.screen == dashboardScreen || m.screen == threadScreen {
return m, m.loadDetails(m.details.PullRequest)
}
return m, m.loadPRs()
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)
} else {
m.move(1)
}
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)
} else {
m.move(-1)
@@ -350,14 +364,21 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.page(1)
case "ctrl+u", "pgup":
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":
if m.screen == prScreen && len(m.prs) > 0 {
m.screen = threadScreen
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, m.loadDetails(m.details.PullRequest)
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.focus = threadDetailPane
@@ -369,12 +390,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "enter":
if m.screen == prScreen && len(m.prs) > 0 {
m.screen = threadScreen
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, m.loadDetails(m.details.PullRequest)
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]
@@ -383,14 +403,42 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "b", "esc":
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, 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
}
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) {
if m.screen == prScreen {
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() {
if m.screen == prScreen {
m.prIndex = 0
} else if m.screen == dashboardScreen {
m.scroll = 0
} else if m.focus == threadDetailPane {
m.scroll = 0
} else {
@@ -541,6 +591,8 @@ func (m *App) toStart() {
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.focus == threadDetailPane {
m.scroll = m.detailMaxScroll()
} else {
@@ -548,6 +600,10 @@ func (m *App) toEnd() {
}
}
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 {
m.scrollDetail(direction * max(3, m.detailViewportHeight()/2))
return
@@ -559,6 +615,18 @@ func (m *App) scrollDetail(delta int) {
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) {
topLines := 3
if m.details.ThreadsTruncated {
@@ -600,6 +668,9 @@ func (m App) View() string {
if m.screen == prScreen {
return m.viewPRs()
}
if m.screen == dashboardScreen {
return m.viewDashboard()
}
return m.viewThreads()
}
@@ -610,17 +681,43 @@ type helpBinding struct {
func (m App) helpBindings() []helpBinding {
if m.screen == prScreen {
openAction := "Open pull request dashboard"
if m.dashboardMode == "hotkey" {
openAction = "Open review threads"
}
return []helpBinding{
{"j / ↓", "Next pull request"},
{"k / ↑", "Previous pull request"},
{"g / G", "First / last pull request"},
{"ctrl-d / ctrl-u", "Page down / up"},
{"enter / l", "Open pull request"},
{"enter / l", openAction},
{"d", "Open pull request dashboard"},
{"r", "Refresh now"},
{"?", "Close this help"},
{"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{
{"h / l", "Focus thread list / detail"},
{"j / k", "Move or scroll focused pane"},
@@ -629,8 +726,9 @@ func (m App) helpBindings() []helpBinding {
{"ctrl-d / ctrl-u", "Page down / up"},
{"tab", "Hide / reveal thread list"},
{"/", "Fuzzy-search file paths"},
{"d", "Open pull request dashboard"},
{"enter / za", "Fold / expand thread"},
{"b / esc", "Return to pull requests"},
{"b / esc", backAction},
{"r", "Refresh now"},
{"?", "Close this help"},
{"q / ctrl-c", "Quit"},
@@ -654,7 +752,9 @@ func (m App) viewHelp() string {
keyWidth := min(17, max(8, contentWidth/3))
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"
}
lines := []string{titleStyle.Render(title)}
@@ -739,7 +839,11 @@ func (m App) viewPRs() string {
}
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 {
@@ -769,6 +873,101 @@ func groupedPRRows(prs []PullRequest, selected int) ([]prListRow, int) {
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 {
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))))
@@ -796,7 +995,7 @@ func (m App) viewThreads() string {
right := m.threadDetail(rightWidth, contentHeight)
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 {
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) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
@@ -179,7 +315,7 @@ func TestContextualHelpOpensAndClosesWithoutLeavingScreen(t *testing.T) {
plain := ansi.Strip(m.View())
if !strings.Contains(plain, "Review thread keys") ||
!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)
}

View File

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