Full Code of dhth/prs for AI

main 58e428bb3254 cached
45 files
125.8 KB
42.2k tokens
187 symbols
1 requests
Download .txt
Repository: dhth/prs
Branch: main
Commit: 58e428bb3254
Files: 45
Total size: 125.8 KB

Directory structure:
gitextract_hwcw2gwt/

├── .gitattributes
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   ├── config.yml
│   │   └── feature_request.md
│   ├── dependabot.yml
│   ├── scripts/
│   │   └── get-yamlfmt.sh
│   └── workflows/
│       ├── main.yml
│       ├── pr.yml
│       ├── release.yml
│       ├── scan.yml
│       └── vulncheck.yml
├── .gitignore
├── .golangci.yml
├── .goreleaser.yaml
├── AGENTS.md
├── LICENSE
├── README.md
├── cmd/
│   ├── config.go
│   └── root.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── internal/
│   └── utils/
│       ├── assets/
│       │   └── gruvbox.json
│       ├── markdown.go
│       └── markdown_test.go
├── main.go
├── ui/
│   ├── assets/
│   │   └── help.md
│   ├── cmds.go
│   ├── colors.go
│   ├── gh.go
│   ├── initial.go
│   ├── model.go
│   ├── msgs.go
│   ├── navigation.go
│   ├── pr_delegate.go
│   ├── prtl_delegate.go
│   ├── render_helpers.go
│   ├── repo_delegate.go
│   ├── styles.go
│   ├── types.go
│   ├── ui.go
│   ├── update.go
│   ├── utils.go
│   └── view.go
└── yamlfmt.yml

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitattributes
================================================
* linguist-detectable=false
*.go linguist-detectable=true


================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help improve prs
title: ''
labels: ''
assignees: ''

---

**Describe the bug**
A clear and concise description of what the bug is.

**Setup**
Please complete the following information along with version numbers, if applicable.
 - `prs` version or the git commit that you've built `prs` on
 - OS [e.g. Ubuntu, macOS]
 - Shell [e.g. zsh, fish]
 - Terminal Emulator [e.g. kitty, iterm]
 - Terminal Multiplexer [e.g. tmux]
 - Locale [e.g. en_US.UTF-8, zh_CN.UTF-8, etc.]

**To Reproduce**
Steps to reproduce the behavior:
1. ...
2. ...
3. Error occurs

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
Add screenshots to help explain your problem.

**Additional context**
Add any other context about the problem here.


================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for prs
title: ''
labels: enhancement
assignees: ''

---

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.


================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
  - package-ecosystem: "gomod"
    directory: "/"
    schedule:
      interval: "monthly"
    groups:
      patch-updates:
        update-types: ["patch"]
      minor-updates:
        update-types: ["minor"]
    labels:
      - "dependencies"
    commit-message:
      prefix: "build"
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "monthly"
    labels:
      - "dependencies"
    commit-message:
      prefix: "ci"


================================================
FILE: .github/scripts/get-yamlfmt.sh
================================================
#!/usr/bin/env bash

set -e

if [ $# -ne 3 ]; then
    echo "Usage: $0 <os> <arch> <version>"
    echo "eg: $0 Linux x86_64 0.13.0"
    exit 1
fi

OS="$1"
ARCH="$2"
VERSION="$3"

cwd=$(pwd)

temp_dir=$(mktemp -d)
if [ ! -e ${temp_dir} ]; then
    echo "Failed to create temporary directory."
    exit 1
fi

cd $temp_dir

curl -sSLO "https://github.com/google/yamlfmt/releases/download/v${VERSION}/yamlfmt_${VERSION}_${OS}_${ARCH}.tar.gz"
curl -sSLO "https://github.com/google/yamlfmt/releases/download/v${VERSION}/checksums.txt"

sha256sum --ignore-missing -c checksums.txt

tar -xzf "yamlfmt_${VERSION}_${OS}_${ARCH}.tar.gz" -C ${temp_dir}/
cd $cwd

cp "${temp_dir}/yamlfmt" .

rm -r ${temp_dir}


================================================
FILE: .github/workflows/main.yml
================================================
name: main

on:
  push:
    branches:
      - 'main'

jobs:
  changes:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      code: ${{ steps.filter.outputs.code }}
      deps: ${{ steps.filter.outputs.deps }}
      release: ${{ steps.filter.outputs.release }}
      workflows: ${{ steps.filter.outputs.workflows }}
      yml: ${{ steps.filter.outputs.yml }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - uses: dorny/paths-filter@v4
        id: filter
        with:
          filters: |
            code:
              - "cmd/**"
              - "internal/**"
              - "ui/**"
              - "**/*.go"
              - "go.*"
              - ".golangci.yml"
              - "main.go"
              - ".github/actions/**"
              - ".github/workflows/main.yml"
            deps:
              - "go.mod"
              - "go.sum"
              - ".github/workflows/main.yml"
            release:
              - ".goreleaser.yaml"
              - ".github/workflows/main.yml"
            workflows:
              - ".github/workflows/**.yml"
            yml:
              - "**.yml"
              - "**.yaml"

  lint:
    needs: changes
    if: ${{ needs.changes.outputs.code == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: dhth/composite-actions/.github/actions/lint-go@main

  build:
    needs: changes
    if: ${{ needs.changes.outputs.code == 'true' }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v6
      - name: Set up Go
        uses: actions/setup-go@v6
        with:
          go-version-file: 'go.mod'
      - name: go build
        run: go build -v ./...

  test:
    needs: changes
    if: ${{ needs.changes.outputs.code == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Set up Go
        uses: actions/setup-go@v6
        with:
          go-version-file: 'go.mod'
      - name: go test
        run: go test -v ./...

  lint-yaml:
    needs: changes
    if: ${{ needs.changes.outputs.yml == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - uses: dhth/composite-actions/.github/actions/lint-yaml@main

  lint-workflows:
    needs: changes
    if: ${{ needs.changes.outputs.workflows == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - uses: dhth/composite-actions/.github/actions/lint-actions@main

  release-check:
    needs: changes
    if: ${{ needs.changes.outputs.release == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Set up Go
        uses: actions/setup-go@v6
        with:
          go-version-file: 'go.mod'
      - name: Release check
        uses: goreleaser/goreleaser-action@v7
        with:
          version: 'v2.9.0'
          args: check

  vulncheck:
    needs: changes
    if: ${{ needs.changes.outputs.deps == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Set up Go
        uses: actions/setup-go@v6
        with:
          go-version-file: 'go.mod'
      - name: install govulncheck
        run: go install golang.org/x/vuln/cmd/govulncheck@latest
      - name: govulncheck
        run: govulncheck ./...


================================================
FILE: .github/workflows/pr.yml
================================================
name: pr

on:
  pull_request:

jobs:
  changes:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: read
    outputs:
      code: ${{ steps.filter.outputs.code }}
      deps: ${{ steps.filter.outputs.deps }}
      release: ${{ steps.filter.outputs.release }}
      workflows: ${{ steps.filter.outputs.workflows }}
      yml: ${{ steps.filter.outputs.yml }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - uses: dorny/paths-filter@v4
        id: filter
        with:
          filters: |
            code:
              - "cmd/**"
              - "internal/**"
              - "ui/**"
              - "**/*.go"
              - "go.*"
              - ".golangci.yml"
              - "main.go"
              - ".github/actions/**"
              - ".github/workflows/pr.yml"
            deps:
              - "go.mod"
              - "go.sum"
              - ".github/workflows/pr.yml"
            release:
              - ".goreleaser.yaml"
              - ".github/workflows/pr.yml"
            workflows:
              - ".github/workflows/**.yml"
            yml:
              - "**.yml"
              - "**.yaml"

  lint:
    needs: changes
    if: ${{ needs.changes.outputs.code == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: dhth/composite-actions/.github/actions/lint-go@main

  build:
    needs: changes
    if: ${{ needs.changes.outputs.code == 'true' }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v6
      - name: Set up Go
        uses: actions/setup-go@v6
        with:
          go-version-file: 'go.mod'
      - name: go build
        run: go build -v ./...

  test:
    needs: changes
    if: ${{ needs.changes.outputs.code == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Set up Go
        uses: actions/setup-go@v6
        with:
          go-version-file: 'go.mod'
      - name: go test
        run: go test -v ./...

  lint-yaml:
    needs: changes
    if: ${{ needs.changes.outputs.yml == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - uses: dhth/composite-actions/.github/actions/lint-yaml@main

  lint-workflows:
    needs: changes
    if: ${{ needs.changes.outputs.workflows == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - uses: dhth/composite-actions/.github/actions/lint-actions@main

  release-check:
    needs: changes
    if: ${{ needs.changes.outputs.release == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Set up Go
        uses: actions/setup-go@v6
        with:
          go-version-file: 'go.mod'
      - name: Release check
        uses: goreleaser/goreleaser-action@v7
        with:
          version: 'v2.9.0'
          args: check

  vulncheck:
    needs: changes
    if: ${{ needs.changes.outputs.deps == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Set up Go
        uses: actions/setup-go@v6
        with:
          go-version-file: 'go.mod'
      - name: install govulncheck
        run: go install golang.org/x/vuln/cmd/govulncheck@latest
      - name: govulncheck
        run: govulncheck ./...


================================================
FILE: .github/workflows/release.yml
================================================
name: release

on:
  push:
    tags:
      - 'v*'

permissions:
  id-token: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
      - name: Set up Go
        uses: actions/setup-go@v6
        with:
          go-version-file: 'go.mod'
      - name: Install Cosign
        uses: sigstore/cosign-installer@v3
        with:
          cosign-release: 'v2.5.0'
      - name: Release Binaries
        uses: goreleaser/goreleaser-action@v7
        with:
          version: 'v2.9.0'
          args: release --clean
        env:
          GITHUB_TOKEN: ${{secrets.GH_PAT}}


================================================
FILE: .github/workflows/scan.yml
================================================
name: scan

on:
  workflow_dispatch:
  schedule:
    - cron: '0 8 10 * *'

jobs:
  virus-total:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Set up Go
        uses: actions/setup-go@v6
        with:
          go-version-file: 'go.mod'
      - name: Build Binaries
        uses: goreleaser/goreleaser-action@v7
        with:
          version: 'v2.9.0'
          args: build --snapshot
      - name: List binaries
        run: |
          ls -lh ./dist/prs_*/prs
      - uses: dhth/composite-actions/.github/actions/scan-files@main
        with:
          files: './dist/prs_*/prs'
          vt-api-key: ${{ secrets.VT_API_KEY }}


================================================
FILE: .github/workflows/vulncheck.yml
================================================
name: vulncheck

on:
  workflow_dispatch:
  schedule:
    - cron: '0 2 * * 2,6'

jobs:
  vulncheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Set up Go
        uses: actions/setup-go@v6
        with:
          go-version-file: 'go.mod'
      - name: install govulncheck
        run: go install golang.org/x/vuln/cmd/govulncheck@latest
      - name: govulncheck
        run: govulncheck ./...


================================================
FILE: .gitignore
================================================
prs
.quickrun
cosign.key
justfile
debug.log


================================================
FILE: .golangci.yml
================================================
version: "2"
linters:
  enable:
    - errname
    - errorlint
    - goconst
    - nilerr
    - prealloc
    - predeclared
    - revive
    - testifylint
    - thelper
    - unconvert
    - usestdlibvars
    - wastedassign
  settings:
    revive:
      rules:
        - name: blank-imports
        - name: context-as-argument
          arguments:
            - allowTypesBefore: '*testing.T'
        - name: context-keys-type
        - name: dot-imports
        - name: empty-block
        - name: error-naming
        - name: error-return
        - name: error-strings
        - name: errorf
        - name: exported
        - name: if-return
        - name: increment-decrement
        - name: indent-error-flow
        - name: package-comments
        - name: range
        - name: receiver-naming
        - name: redefines-builtin-id
        - name: superfluous-else
        - name: time-naming
        - name: unexported-return
        - name: unreachable-code
        - name: unused-parameter
        - name: var-declaration
        - name: unnecessary-stmt
        - name: confusing-naming
        - name: unused-receiver
        - name: unhandled-error
          arguments:
            - fmt.Print
            - fmt.Println
            - fmt.Printf
            - fmt.Fprintf
            - fmt.Fprint
  exclusions:
    generated: lax
    presets:
      - comments
      - common-false-positives
      - legacy
      - std-error-handling
    paths:
      - third_party$
      - builtin$
      - examples$
formatters:
  enable:
    - gofumpt
  exclusions:
    generated: lax
    paths:
      - third_party$
      - builtin$
      - examples$


================================================
FILE: .goreleaser.yaml
================================================
version: 2

release:
  draft: true

before:
  hooks:
    - go mod tidy
    - go generate ./...

builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
    goarch:
      - amd64
      - arm64

signs:
  - cmd: cosign
    signature: "${artifact}.sig"
    certificate: "${artifact}.pem"
    args:
      - "sign-blob"
      - "--oidc-issuer=https://token.actions.githubusercontent.com"
      - "--output-certificate=${certificate}"
      - "--output-signature=${signature}"
      - "${artifact}"
      - "--yes"
    artifacts: checksum

brews:
  - name: prs
    repository:
      owner: dhth
      name: homebrew-tap
    directory: Formula
    license: MIT
    homepage: "https://github.com/dhth/prs"
    description: "Stay updated on pull requests from your terminal"

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"
      - "^ci:"


================================================
FILE: AGENTS.md
================================================
# AGENTS.md

This file provides guidance to AI coding agents when working with code in this repository.

## Project Overview

`prs` is a Go terminal UI application for browsing GitHub pull requests. It uses the Bubble Tea TUI framework with GitHub GraphQL API integration via `gh` CLI authentication.

## Common Commands

```bash
just build      # go build -ldflags='-s -w' .
just run        # go run .
just install    # go install -ldflags='-s -w' .
just lint       # golangci-lint run
just fmt        # gofumpt -w .
```

Note: always use `just` to run commands.

## Architecture

The app follows the standard **Bubble Tea** (Elm-architecture) pattern: `Model` → `Update(msg)` → `View()` cycle.

**Entry flow**: `main.go` → `cmd.Execute()` (Cobra CLI) → `ui.RenderUI()` → `tea.NewProgram(InitialModel())`

### Key packages

- **`cmd/`** — Cobra CLI setup, config loading (Viper). Config priority: flags > env vars (`PRS_*`) > config file.
- **`ui/`** — All TUI logic, split by concern:
  - `model.go` — Central Bubble Tea `Model` struct (state)
  - `update.go` — Event handling (largest file)
  - `view.go` — View rendering
  - `cmds.go` — Async `tea.Cmd` functions (GitHub API calls)
  - `msgs.go` — Message types passed through Bubble Tea
  - `gh.go` — GitHub GraphQL queries
  - `types.go` — GraphQL query/response type definitions
  - `navigation.go` — View/section navigation with back-stack (`activePane`, `lastPane`, `secondLastActivePane`)
  - `styles.go` / `colors.go` — Lipgloss terminal styling
  - `render_helpers.go` — Display formatting helpers
- **`internal/utils/`** — Markdown rendering with Glamour (embedded Gruvbox theme)

### View system

Six panes managed by `activePane` enum: `repoListView`, `prListView`, `prDetailsView`, `prTLListView`, `prTLItemDetailView`, `helpView`.

PR details has sub-sections: metadata, description, checks, references, files, commits, comments.

### Caching

PR details and timeline data are cached in maps keyed by `"owner/repo:number"` to avoid redundant API calls.

## Linting

Uses golangci-lint v2 with `gofumpt` formatter and `revive` linter (among others). See `.golangci.yml` for full config.


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2024-2026 Dhruv Thakur

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
<p align="center">
  <h1 align="center">prs</h1>
  <p align="center">
    <a href="https://github.com/dhth/prs/actions/workflows/main.yml"><img alt="build status" src="https://img.shields.io/github/actions/workflow/status/dhth/prs/main.yml?style=flat-square"></a>
    <a href="https://github.com/dhth/prs/actions/workflows/vulncheck.yml"><img alt="vuln check" src="https://img.shields.io/github/actions/workflow/status/dhth/prs/vulncheck.yml?style=flat-square&label=vulncheck"></a>
    <a href="https://github.com/dhth/prs/releases/latest"><img alt="Latest release" src="https://img.shields.io/github/release/dhth/prs.svg?style=flat-square"></a>
    <a href="https://github.com/dhth/prs/releases/latest"><img alt="Commits since latest release" src="https://img.shields.io/github/commits-since/dhth/prs/latest?style=flat-square"></a>
  </p>
</p>

`prs` lets you stay updated on pull requests from your terminal.

<p align="center">
  <img src="https://tools.dhruvs.space/images/prs/v1-0-0/prs.gif" alt="Usage" />
</p>

[source video](https://youtu.be/H81ru9cQhDo)

🤔 Motivation
---

For my day job as a tech lead, I need to stay updated on several PRs, and my
hope is that `prs` will let me do that faster than the Github web UI (or other
tools for that matter).

💾 Installation
---

**homebrew**:

```sh
brew install dhth/tap/prs
```

**go**:

```sh
go install github.com/dhth/prs@latest
```

Or get the binaries directly from a [release][3]. Read more about verifying the
authenticity of released artifacts [here](#-verifying-release-artifacts).

🔑 Authentication
---

You can have `prs` make authenticated calls to GitHub on your behalf in either
of two ways:

- Have an authenticated instance of [gh](https://github.com/cli/cli) available
    (recommended).
- Provide a valid Github token via `$GH_TOKEN`.

⚡️ Usage
---

`prs` has two modes:

- **Query mode** (default): lets you search PRs based on a query you provide (based
  on github's [search
  syntax](https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests))
- **Repos mode**: let you pick a repository from a predefined list

### Query Mode

```shell
prs --query='type:pr repo:neovim/neovim state:open label:lua linked:issue'

# view open PRs where you're the author
prs -q 'type:pr author:@me state:open'

# view open PRs where a review has been requested from you
PRS_QUERY='type:pr user-review-requested:@me state:open' prs

# read query from prs' config file
prs
```

### Repos Mode

```shell
prs --mode=repos --repos='dhth/prs,dhth/omm,dhth/hours'

PRS_REPOS='dhth/prs,dhth/omm,dhth/hours' prs --mode=repos

# read repos from prs' config file
prs -m repos
```

🛠️ Configuration
---

`prs` accepts configuration from any of the following:

- Command line flags (run `prs -h` for details)
- Environment variables (eg. `PRS_QUERY`)
- `prs`'s config file, which looks like this:

    ```yaml
    num: 20
    repos:
      - dhth/omm
      - dhth/hours
      - dhth/prs
      - neovim/neovim
      - junegunn/fzf
      - BurntSushi/ripgrep
      - charmbracelet/bubbletea
      - goreleaser/goreleaser
      - dandavison/delta
    query: 'type:pr repo:neovim/neovim state:open label:lua linked:issue'
    ```

For every configuration property, the order of priority is: `flag >>
environment variables >> config file`, ie, flags take the highest priority.

**[`^ back to top ^`](#prs)**

Screenshots
---

### PR List View

![Screen 1](https://tools.dhruvs.space/images/prs/v1-0-0/prs-1.png)

### PR Timeline List View

![Screen 2](https://tools.dhruvs.space/images/prs/v1-0-0/prs-2.png)

### PR Timeline Item Detail View
![Screen 3](https://tools.dhruvs.space/images/prs/v1-0-0/prs-3.png)

### PR Details View

![Screen 4](https://tools.dhruvs.space/images/prs/v1-0-0/prs-4.png)

![Screen 5](https://tools.dhruvs.space/images/prs/v1-0-0/prs-5.png)

![Screen 6](https://tools.dhruvs.space/images/prs/v1-0-0/prs-6.png)

![Screen 7](https://tools.dhruvs.space/images/prs/v1-0-0/prs-7.png)

**[`^ back to top ^`](#prs)**

Keyboard Shortcuts
---

### General

```text
  q/esc/ctrl+c                      go back
  Q                                 quit from anywhere
  ?                                 Open Help View
  d                                 Open PR Details View
  ctrl+v                            Show PR details using gh
```

### PR List View

```text
  Indicators for current review decision:

  ±  implies                        CHANGES_REQUESTED
  🟡 implies                        REVIEW_REQUIRED
  ✅ implies                        APPROVED

  ⏎/tab/shift+tab/2                 Switch focus to PR Timeline View
  ctrl+s                            Switch focus to Repo List View (when --mode=repos)
  ctrl+d                            Show PR diff
  ctrl+r                            Reload PR list
  ctrl+b                            Open PR in browser
```

### PR Details View

```text
  h/N/←                             Go to previous section
  l/n/→                             Go to next section
  1/2/3...                          Go to specific section
  J/]                               Go to next PR
  K/[                               Go to previous PR
  d                                 Go back to last view
  ctrl+b                            Open PR in browser
```

### Timeline List View


```text
  tab/shift+tab/1                   Switch focus to PR List View
  ⏎/3                               Show details for PR timeline item (when applicable)
  ctrl+d                            Show PR diff
  ctrl+b                            Open timeline item in browser
  ctrl+r                            Reload PR timeline
```

### Timeline Item Detail View


```text
  1                                 Switch focus to PR List View
  2                                 Switch focus to PR Timeline List View
  ctrl+d                            Show PR diff
  ctrl+b                            Open timeline item in browser
  h/N/←                             Go to previous section
  l/n/→                             Go to next section
```

### 🔐 Verifying release artifacts

In case you get the `prs` binary directly from a [release][3], you may want to
verify its authenticity. Checksums are applied to all released artifacts, and
the resulting checksum file is signed using
[cosign](https://docs.sigstore.dev/cosign/installation/).

Steps to verify (replace the version in the commands listed with the one you
want):

1. Download the following files from the release:

   - prs_1.0.0_checksums.txt
   - prs_1.0.0_checksums.txt.pem
   - prs_1.0.0_checksums.txt.sig

2. Verify the signature:

   ```shell
   cosign verify-blob prs_1.0.0_checksums.txt \
       --certificate prs_1.0.0_checksums.txt.pem \
       --signature prs_1.0.0_checksums.txt.sig \
       --certificate-identity-regexp 'https://github\.com/dhth/prs/\.github/workflows/.+' \
       --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
   ```

3. Download the compressed archive you want, and validate its checksum:

   ```shell
   curl -sSLO https://github.com/dhth/prs/releases/download/v1.0.0/prs_1.0.0_linux_amd64.tar.gz
   sha256sum --ignore-missing -c prs_1.0.0_checksums.txt
   ```

3. If checksum validation goes through, uncompress the archive:

   ```shell
   tar -xzf prs_1.0.0_linux_amd64.tar.gz
   ./prs
   # profit!
   ```

Acknowledgements
---

`prs` is built using [bubbletea][1], and released via [goreleaser][2].

[1]: https://github.com/charmbracelet/bubbletea
[2]: https://github.com/goreleaser/goreleaser
[3]: https://github.com/dhth/prs/releases

**[`^ back to top ^`](#prs)**


================================================
FILE: cmd/config.go
================================================
package cmd

import (
	"os"
	"os/user"
	"strings"
)

func expandTilde(path string) string {
	if strings.HasPrefix(path, "~") {
		usr, err := user.Current()
		if err != nil {
			os.Exit(1)
		}
		return strings.Replace(path, "~", usr.HomeDir, 1)
	}
	return path
}


================================================
FILE: cmd/root.go
================================================
package cmd

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"time"

	ghapi "github.com/cli/go-gh/v2/pkg/api"
	"github.com/dhth/prs/ui"
	"github.com/spf13/cobra"
	"github.com/spf13/pflag"
	"github.com/spf13/viper"
)

const (
	envPrefix          = "PRS"
	author             = "@dhth"
	projectHomePage    = "https://github.com/dhth/prs"
	issuesURL          = "https://github.com/dhth/prs/issues"
	configFileName     = "prs/prs.yml"
	defaultSearchQuery = "type:pr author:@me sort:updated-desc state:open"
	defaultPRNum       = 20
	maxPRNum           = 50
)

var (
	errCouldntGetHomeDir        = errors.New("couldn't get home directory")
	errCouldntGetConfigDir      = errors.New("couldn't get config directory")
	errModeIncorrect            = errors.New("incorrect mode provided")
	errConfigFileDoesntExist    = errors.New("config file does not exist")
	errNoReposProvided          = errors.New("no repos were provided")
	errIncorrectRepoProvided    = errors.New("incorrect repo provided")
	errCouldntSetupGithubClient = errors.New("couldn't set up a Github Client")
)

var reportIssueMsg = fmt.Sprintf("Let %s know about this error via %s.", author, issuesURL)

func Execute(version string) error {
	rootCmd, err := NewRootCommand(version)
	if err != nil {
		fmt.Printf("Error: %s\n", err.Error())
		switch {
		case errors.Is(err, errCouldntGetHomeDir), errors.Is(err, errCouldntGetConfigDir):
			fmt.Printf(`
This is a fatal error; use --config-path to specify config file path manually.
%s
`, reportIssueMsg)
		}
		return err
	}

	err = rootCmd.Execute()

	if errors.Is(err, errCouldntSetupGithubClient) {
		fmt.Printf(`
If the error is due to misconfigured authentication, you can fix that by either of the following:
- Provide a valid Github token via $GH_TOKEN
- Have an authenticated instance of gh (https://github.com/cli/cli) available
`)
	}
	return err
}

func NewRootCommand(version string) (*cobra.Command, error) {
	var (
		configFilePath string
		configPathFull string
		mode           ui.Mode
		modeInp        string
		repoStrs       []string
		repos          []ui.Repo
		searchQuery    string
		ghClient       *ghapi.GraphQLClient
		prNum          int
	)

	rootCmd := &cobra.Command{
		Use:   "prs",
		Short: "prs lets you stay updated on pull requests from your terminal",
		Long: fmt.Sprintf(`prs lets you stay updated on pull requests from your terminal.

Use it to query for specific pull requests based on a filter query (using
Github's search syntax), or have it let you pick a repository from a predefined
list.

Examples:
$ prs --query='type:pr repo:neovim/neovim state:open label:lua linked:issue'
$ prs -q 'type:pr author:@me state:open'
$ PRS_QUERY='type:pr user-review-requested:@me state:open' prs
$ prs # will read query from config file

$ prs --mode=repos --repos='dhth/prs,dhth/omm,dhth/hours'
$ PRS_REPOS='dhth/prs,dhth/omm,dhth/hours' prs --mode=repos
$ prs -m repos # will read repos from config file

Project home page: %s
`, projectHomePage),

		Args:         cobra.MaximumNArgs(0),
		SilenceUsage: true,
		Version:      version,
		PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
			configPathFull = expandTilde(configFilePath)

			if filepath.Ext(configPathFull) != ".yml" {
				return errConfigFileDoesntExist
			}
			_, err := os.Stat(configPathFull)

			fl := cmd.Flags()
			if fl != nil {
				cf := fl.Lookup("config-path")
				if cf != nil && cf.Changed && errors.Is(err, fs.ErrNotExist) {
					return errConfigFileDoesntExist
				}
			}

			var v *viper.Viper
			v, err = initializeConfig(cmd, configPathFull)
			if err != nil {
				return err
			}

			if prNum > maxPRNum {
				prNum = maxPRNum
			}

			switch modeInp {
			case "repos":
				mode = ui.RepoMode
			case "query":
				mode = ui.QueryMode
			default:
				return errModeIncorrect
			}

			if mode == ui.RepoMode {
				var reposToUse []string
				// pretty ugly hack to get around the fact that
				// v.GetStringSlice("repos") always seems to prioritize the config file
				if len(repoStrs) > 0 && len(repoStrs[0]) > 0 && !strings.HasPrefix(repoStrs[0], "[") {
					reposToUse = repoStrs
				} else {
					reposToUse = v.GetStringSlice("repos")
				}

				if len(reposToUse) == 0 {
					return errNoReposProvided
				}

				for _, r := range reposToUse {
					repoEls := strings.Split(r, "/")
					// TODO: there can be more validations done here, maybe regex based
					if len(repoEls) != 2 {
						return fmt.Errorf("%w: %s", errIncorrectRepoProvided, r)
					}

					repos = append(repos, ui.Repo{
						Owner: strings.TrimSpace(repoEls[0]),
						Name:  strings.TrimSpace(repoEls[1]),
					})
				}
			}

			opts := ghapi.ClientOptions{
				EnableCache: true,
				CacheTTL:    time.Second * 30,
				Timeout:     8 * time.Second,
			}

			ghClient, err = ghapi.NewGraphQLClient(opts)
			if err != nil {
				return fmt.Errorf("%w: %s", errCouldntSetupGithubClient, err.Error())
			}
			return nil
		},
		RunE: func(_ *cobra.Command, _ []string) error {
			config := ui.Config{
				PRCount: prNum,
				Repos:   repos,
				Query:   &searchQuery,
			}
			return ui.RenderUI(ghClient, config, mode)
		},
	}
	rootCmd.SetVersionTemplate(`{{with .Name}}{{printf "%s " .}}{{end}}{{printf "%s" .Version}}
`)

	ros := runtime.GOOS
	userCfgDir, err := os.UserConfigDir()
	if err != nil {
		return nil, fmt.Errorf("%w: %s", errCouldntGetConfigDir, err.Error())
	}

	var defaultConfigFilePath string
	switch ros {
	case "linux", "windows":
		defaultConfigFilePath = filepath.Join(userCfgDir, configFileName)
	default:
		// to use ~/.config instead of $HOME/Library/Application Support
		hd, err := os.UserHomeDir()
		if err != nil {
			return nil, fmt.Errorf("%w: %s", errCouldntGetHomeDir, err.Error())
		}
		defaultConfigFilePath = filepath.Join(hd, ".config", configFileName)
	}

	rootCmd.Flags().StringVarP(&configFilePath, "config-path", "c", defaultConfigFilePath, "location of prs's config file")
	rootCmd.Flags().StringVarP(&modeInp, "mode", "m", "query", "mode to run prs in; values: query, repos")
	rootCmd.Flags().StringVarP(&searchQuery, "query", "q", defaultSearchQuery, "query to search PRs for")
	rootCmd.Flags().IntVarP(&prNum, "num", "n", defaultPRNum, "number of PRs to fetch")
	rootCmd.Flags().StringSliceVarP(&repoStrs, "repos", "r", nil, "comma separated list of repos to use for repo mode")

	rootCmd.CompletionOptions.DisableDefaultCmd = true

	return rootCmd, nil
}

func initializeConfig(cmd *cobra.Command, configFile string) (*viper.Viper, error) {
	v := viper.New()

	v.SetConfigName(filepath.Base(configFile))
	v.SetConfigType("yaml")
	v.AddConfigPath(filepath.Dir(configFile))

	err := v.ReadInConfig()
	if err != nil && !errors.As(err, &viper.ConfigFileNotFoundError{}) {
		return v, err
	}

	v.SetEnvPrefix(envPrefix)
	v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
	v.AutomaticEnv()

	err = bindFlags(cmd, v)
	if err != nil {
		return v, err
	}

	return v, nil
}

func bindFlags(cmd *cobra.Command, v *viper.Viper) error {
	var err error
	cmd.Flags().VisitAll(func(f *pflag.Flag) {
		if !f.Changed && v.IsSet(f.Name) {
			val := v.Get(f.Name)
			fErr := cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val))
			if fErr != nil {
				err = fErr
				return
			}
		}
	})
	return err
}


================================================
FILE: docker-compose.yml
================================================
version: '3.8'

# to test workings on linux
services:
  prs-dev:
    image: golang:1.23-alpine
    volumes:
      - .:/go/src/app
    working_dir: /go/src/app
    command: sleep infinity


================================================
FILE: go.mod
================================================
module github.com/dhth/prs

go 1.26.3

require (
	charm.land/bubbles/v2 v2.1.0
	charm.land/bubbletea/v2 v2.0.6
	charm.land/lipgloss/v2 v2.0.3
	github.com/charmbracelet/glamour v1.0.0
	github.com/cli/go-gh/v2 v2.13.0
	github.com/cli/shurcooL-graphql v0.0.4
	github.com/dustin/go-humanize v1.0.1
	github.com/muesli/termenv v0.16.0
	github.com/spf13/cobra v1.10.2
	github.com/spf13/pflag v1.0.10
	github.com/spf13/viper v1.21.0
	github.com/stretchr/testify v1.11.1
)

require (
	github.com/alecthomas/chroma/v2 v2.20.0 // indirect
	github.com/atotto/clipboard v0.1.4 // indirect
	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
	github.com/aymerick/douceur v0.2.0 // indirect
	github.com/charmbracelet/colorprofile v0.4.3 // indirect
	github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
	github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
	github.com/charmbracelet/x/ansi v0.11.7 // indirect
	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
	github.com/charmbracelet/x/exp/slice v0.0.0-20250812135814-932da4e322f4 // indirect
	github.com/charmbracelet/x/term v0.2.2 // indirect
	github.com/charmbracelet/x/termios v0.1.1 // indirect
	github.com/charmbracelet/x/windows v0.2.2 // indirect
	github.com/cli/safeexec v1.0.1 // indirect
	github.com/clipperhouse/displaywidth v0.11.0 // indirect
	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
	github.com/dlclark/regexp2 v1.11.5 // indirect
	github.com/fsnotify/fsnotify v1.9.0 // indirect
	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
	github.com/gorilla/css v1.0.1 // indirect
	github.com/henvic/httpretty v0.1.4 // indirect
	github.com/inconshreveable/mousetrap v1.1.0 // indirect
	github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/mattn/go-runewidth v0.0.23 // indirect
	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
	github.com/muesli/cancelreader v0.2.2 // indirect
	github.com/muesli/reflow v0.3.0 // indirect
	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
	github.com/rivo/uniseg v0.4.7 // indirect
	github.com/sagikazarmark/locafero v0.11.0 // indirect
	github.com/sahilm/fuzzy v0.1.1 // indirect
	github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
	github.com/spf13/afero v1.15.0 // indirect
	github.com/spf13/cast v1.10.0 // indirect
	github.com/subosito/gotenv v1.6.0 // indirect
	github.com/thlib/go-timezone-local v0.0.7 // indirect
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
	github.com/yuin/goldmark v1.7.13 // indirect
	github.com/yuin/goldmark-emoji v1.0.6 // indirect
	go.yaml.in/yaml/v3 v3.0.4 // indirect
	golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
	golang.org/x/net v0.43.0 // indirect
	golang.org/x/sync v0.20.0 // indirect
	golang.org/x/sys v0.43.0 // indirect
	golang.org/x/term v0.36.0 // indirect
	golang.org/x/text v0.30.0 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)


================================================
FILE: go.sum
================================================
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/slice v0.0.0-20250812135814-932da4e322f4 h1:00G7Z522s/WiCooTYFKATlN5Gjn64miEheomXCPwRcU=
github.com/charmbracelet/x/exp/slice v0.0.0-20250812135814-932da4e322f4/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/cli/go-gh/v2 v2.13.0 h1:jEHZu/VPVoIJkciK3pzZd3rbT8J90swsK5Ui4ewH1ys=
github.com/cli/go-gh/v2 v2.13.0/go.mod h1:Us/NbQ8VNM0fdaILgoXSz6PKkV5PWaEzkJdc9vR2geM=
github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY=
github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU=
github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/thlib/go-timezone-local v0.0.7 h1:fX8zd3aJydqLlTs/TrROrIIdztzsdFV23OzOQx31jII=
github.com/thlib/go-timezone-local v0.0.7/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=


================================================
FILE: internal/utils/assets/gruvbox.json
================================================
{
  "document": {
    "block_prefix": "",
    "block_suffix": "",
    "color": "#fbf1c7",
    "margin": 2
  },
  "block_quote": {
    "indent": 1,
    "indent_token": "┃ "
  },
  "paragraph": {},
  "list": {
    "level_indent": 2
  },
  "heading": {
    "block_suffix": "\n",
    "color": "#fe8019",
    "bold": true
  },
  "h1": {
    "prefix": "# ",
    "suffix": "",
    "color": "#d3869b",
    "bold": true
  },
  "h2": {
    "prefix": "## ",
    "color": "#83a598"
  },
  "h3": {
    "prefix": "### ",
    "color": "#83a598"
  },
  "h4": {
    "prefix": "#### ",
    "color": "#83a598"
  },
  "h5": {
    "prefix": "",
    "color": "#fabd2f"
  },
  "h6": {
    "prefix": "",
    "color": "#8ec07c",
    "bold": false
  },
  "text": {},
  "strikethrough": {
    "crossed_out": true
  },
  "emph": {
    "color": "#83a598",
    "italic": true,
    "prefix": " ",
    "suffix": " "
  },
  "strong": {
    "color": "#fe8019",
    "bold": true
  },
  "hr": {
    "color": "#928374",
    "format": "\n--------\n"
  },
  "item": {
    "block_prefix": "• "
  },
  "enumeration": {
    "block_prefix": ". "
  },
  "task": {
    "ticked": "[✓] ",
    "unticked": "[ ] "
  },
  "link": {
    "color": "#83a598",
    "underline": true
  },
  "link_text": {
    "color": "#fabd2f",
    "bold": true
  },
  "image": {
    "color": "132",
    "underline": true
  },
  "image_text": {
    "color": "245",
    "format": "Image: {{.text}} →"
  },
  "code": {
    "color": "#fabd2f",
    "bold": false
  },
  "code_block": {
    "color": "244",
    "chroma": {
      "text": {
        "color": "#fbf1c7"
      },
      "error": {
        "color": "#282828",
        "background_color": "#fb4934"
      },
      "comment": {
        "color": "#928374"
      },
      "comment_preproc": {
        "color": "#8ec07c"
      },
      "keyword": {
        "color": "#fe8019"
      },
      "keyword_reserved": {
        "color": "#8ec07c"
      },
      "keyword_namespace": {
        "color": "#d3869b"
      },
      "keyword_type": {
        "color": "#fabd2f"
      },
      "operator": {
        "color": "#fe8019"
      },
      "punctuation": {
        "color": "#928374"
      },
      "name": {
        "color": "#ebdbb2"
      },
      "name_builtin": {
        "color": "#fabd2f"
      },
      "name_tag": {
        "color": "#fb4934"
      },
      "name_attribute": {
        "color": "#b8bb26"
      },
      "name_class": {
        "color": "#fe8019"
      },
      "name_constant": {
        "color": "#d3869b"
      },
      "name_decorator": {
        "color": "#d3869b"
      },
      "name_exception": {
        "color": "#fb4934"
      },
      "name_function": {
        "color": "#fabd2f"
      },
      "name_other": {},
      "literal": {},
      "literal_number": {
        "color": "#fabd2f"
      },
      "literal_date": {},
      "literal_string": {
        "color": "#b8bb26"
      },
      "literal_string_escape": {
        "color": "#83a598"
      },
      "generic_deleted": {
        "color": "#fb4934"
      },
      "generic_emph": {
        "color": "#83a598",
        "italic": true
      },
      "generic_inserted": {
        "color": "#b8bb26"
      },
      "generic_strong": {
        "color": "#ebdbb2",
        "bold": true
      },
      "generic_subheading": {
        "color": "#b8bb26"
      },
      "background": {
        "background_color": "#282828"
      }
    }
  },
  "table": {
    "center_separator": "┼",
    "column_separator": "│",
    "row_separator": "─"
  },
  "definition_list": {},
  "definition_term": {},
  "definition_description": {
    "block_prefix": "\n🠶 "
  },
  "html_block": {},
  "html_span": {}
}


================================================
FILE: internal/utils/markdown.go
================================================
package utils

import (
	_ "embed"

	"github.com/charmbracelet/glamour"
	"github.com/muesli/termenv"
)

//go:embed assets/gruvbox.json
var glamourJSONBytes []byte

func GetMarkDownRenderer(wrap int) (*glamour.TermRenderer, error) {
	return glamour.NewTermRenderer(
		glamour.WithStylesFromJSONBytes(glamourJSONBytes),
		glamour.WithColorProfile(termenv.TrueColor),
		glamour.WithWordWrap(wrap),
	)
}


================================================
FILE: internal/utils/markdown_test.go
================================================
package utils

import (
	"encoding/json"
	"testing"

	"github.com/charmbracelet/glamour"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestGetGlamourStyleFromFile(t *testing.T) {
	gotOption := glamour.WithStylesFromJSONBytes(glamourJSONBytes)
	renderer, err := glamour.NewTermRenderer(gotOption)
	require.NoError(t, err)
	assert.NotNil(t, renderer)

	_, err = renderer.Render("a")
	assert.NoError(t, err)
}

func TestGlamourStylesFileIsValid(t *testing.T) {
	got := json.Valid(glamourJSONBytes)
	assert.True(t, got)
}


================================================
FILE: main.go
================================================
package main

import (
	"os"
	"runtime/debug"

	"github.com/dhth/prs/cmd"
)

var version = "dev"

func main() {
	v := version
	if version == "dev" {
		info, ok := debug.ReadBuildInfo()
		if ok {
			v = info.Main.Version
		}
	}
	err := cmd.Execute(v)
	if err != nil {
		os.Exit(1)
	}
}


================================================
FILE: ui/assets/help.md
================================================
# prs Reference Manual

(scroll line by line with j/k/arrow keys or by half a page with c-d/c-u)

## Views

prs has 6 views:

- PR List View
- PR Details View
- PR Timeline List View
- PR Timeline Item Detail View
- Repo List View (only applicable when --mode=repos)
- Help View (this one)

## Keyboard Shortcuts

### General

```text
  q/esc/ctrl+c                      go back
  Q                                 quit from anywhere
  ?                                 Open Help View
  d                                 Open PR Details View
  ctrl+v                            Show PR details using gh
```

### PR List View

```text
  Indicators for current review decision:

  ±  implies                        CHANGES_REQUESTED
  🟡 implies                        REVIEW_REQUIRED
  ✅ implies                        APPROVED

  ⏎/tab/shift+tab/2                 Switch focus to PR Timeline View
  ctrl+s                            Switch focus to Repo List View (when --mode=repos)
  ctrl+d                            Show PR diff
  ctrl+r                            Reload PR list
  ctrl+b                            Open PR in browser
```

### PR Details View

```text
  h/N/←                             Go to previous section
  l/n/→                             Go to next section
  1/2/3...                          Go to specific section
  J/]                               Go to next PR
  K/[                               Go to previous PR
  d                                 Go back to last view
  ctrl+b                            Open PR in browser
```

### Timeline List View


```text
  tab/shift+tab/1                   Switch focus to PR List View
  ⏎/3                               Show details for PR timeline item (when applicable)
  ctrl+d                            Show PR diff
  ctrl+b                            Open timeline item in browser
  ctrl+r                            Reload PR timeline
```

### Timeline Item Detail View


```text
  1                                 Switch focus to PR List View
  2                                 Switch focus to PR Timeline List View
  ctrl+d                            Show PR diff
  ctrl+b                            Open timeline item in browser
  h/N/←                             Go to previous section
  l/n/→                             Go to next section
```


================================================
FILE: ui/cmds.go
================================================
package ui

import (
	"errors"
	"fmt"
	"os/exec"
	"runtime"
	"time"

	tea "charm.land/bubbletea/v2"
	ghapi "github.com/cli/go-gh/v2/pkg/api"
)

var errOSNotSupported = errors.New("OS not supported")

func chooseRepo(repo string) tea.Cmd {
	return func() tea.Msg {
		return repoChosenMsg{repo}
	}
}

func openURLInBrowser(url string) tea.Cmd {
	return func() tea.Msg {
		var openCmd string
		var args []string

		switch runtime.GOOS {
		case "darwin":
			openCmd = "open"
			args = []string{url}
		case "linux":
			openCmd = "xdg-open"
			args = []string{url}
		case "windows":
			openCmd = "rundll32"
			args = []string{"url.dll,FileProtocolHandler", url}
		default:
			return urlOpenedinBrowserMsg{url: url, err: errOSNotSupported}
		}

		c := exec.Command(openCmd, args...)
		err := c.Run()

		return urlOpenedinBrowserMsg{url: url, err: err}
	}
}

func showDiff(repoOwner, repoName string, prNumber int) tea.Cmd {
	cmd := []string{
		"gh",
		"--repo",
		fmt.Sprintf("%s/%s", repoOwner, repoName),
		"pr",
		"diff",
		fmt.Sprintf("%d", prNumber),
	}
	c := exec.Command(cmd[0], cmd[1:]...)

	return tea.ExecProcess(c, func(err error) tea.Msg {
		if err != nil {
			return prDiffDoneMsg{err: err}
		}
		return tea.Msg(prDiffDoneMsg{})
	})
}

func showPR(repoOwner, repoName string, prNumber int) tea.Cmd {
	cmd := []string{
		"gh",
		"--repo",
		fmt.Sprintf("%s/%s", repoOwner, repoName),
		"pr",
		"view",
		"--comments",
		fmt.Sprintf("%d", prNumber),
	}
	c := exec.Command(cmd[0], cmd[1:]...)

	return tea.ExecProcess(c, func(err error) tea.Msg {
		if err != nil {
			return prDiffDoneMsg{err: err}
		}
		return tea.Msg(prDiffDoneMsg{})
	})
}

func hideHelp(interval time.Duration) tea.Cmd {
	return tea.Tick(interval, func(time.Time) tea.Msg {
		return hideHelpMsg{}
	})
}

func fetchPRSFromQuery(ghClient *ghapi.GraphQLClient, queryStr string, prCount int) tea.Cmd {
	return func() tea.Msg {
		prs, err := getPRDataFromQuery(ghClient, queryStr, prCount)
		return prsFetchedMsg{prs, err}
	}
}

func fetchPRSForRepo(ghClient *ghapi.GraphQLClient, repoOwner string, repoName string, prCount int) tea.Cmd {
	return func() tea.Msg {
		queryStr := fmt.Sprintf("type:pr repo:%s/%s sort:updated-desc", repoOwner, repoName)
		prs, err := getPRDataFromQuery(ghClient, queryStr, prCount)
		return prsFetchedMsg{prs, err}
	}
}

func fetchPRMetadata(ghClient *ghapi.GraphQLClient, repoOwner, repoName string, prNumber int) tea.Cmd {
	return func() tea.Msg {
		metadata, err := getPRMetadata(ghClient, repoOwner, repoName, prNumber)
		return prMetadataFetchedMsg{repoOwner, repoName, prNumber, metadata, err}
	}
}

func fetchPRTLItems(ghClient *ghapi.GraphQLClient, repoOwner string, repoName string, prNumber int, tlItemsCount int, setItems bool) tea.Cmd {
	return func() tea.Msg {
		prTLItems, err := getPRTLData(ghClient, repoOwner, repoName, prNumber, tlItemsCount)
		return prTLFetchedMsg{repoOwner, repoName, prNumber, prTLItems, setItems, err}
	}
}


================================================
FILE: ui/colors.go
================================================
package ui

var colors = []string{
	"#fe77a4",
	"#d3869a",
	"#ff4c8b",
	"#ffb0c2",
	"#df748b",
	"#ff6682",
	"#f19597",
	"#d89e9d",
	"#fc5260",
	"#e96462",
	"#ffb5a2",
	"#febcac",
	"#f0947b",
	"#ff6334",
	"#af9084",
	"#ff5405",
	"#e98658",
	"#be876e",
	"#ff803b",
	"#fd780b",
	"#ff9743",
	"#e2ac85",
	"#d67717",
	"#d4925c",
	"#ffb472",
	"#fe9103",
	"#de9644",
	"#dc8b00",
	"#ffb13c",
	"#c9b094",
	"#faca7d",
	"#c7921f",
	"#c6a267",
	"#d3cdc5",
	"#fabd2f",
	"#dcad50",
	"#daa402",
	"#ffc20c",
	"#fbcf56",
	"#b29807",
	"#e7c727",
	"#c7b648",
	"#9c9360",
	"#cec48b",
	"#bbb206",
	"#ddd601",
	"#d1cc74",
	"#b8bb26",
	"#acaa5e",
	"#b4c800",
	"#a6b92b",
	"#a8b64c",
	"#aab08a",
	"#849843",
	"#a8d906",
	"#a8a9a3",
	"#88b500",
	"#add562",
	"#a0d845",
	"#8de107",
	"#829b60",
	"#7db839",
	"#94bc63",
	"#71c200",
	"#b5d092",
	"#6e9f3a",
	"#51a100",
	"#b5e48c",
	"#8ce852",
	"#59d412",
	"#89d967",
	"#59c435",
	"#4ba539",
	"#00b700",
	"#00db04",
	"#9ae089",
	"#6fbd63",
	"#83b87a",
	"#5ddb63",
	"#04eb4d",
	"#7a9879",
	"#00ce48",
	"#05b64c",
	"#9cdea5",
	"#64d97f",
	"#8fbc96",
	"#4daa67",
	"#00d977",
	"#12b667",
	"#6ed999",
	"#63bd8f",
	"#00d990",
	"#a7e0c2",
	"#0abe88",
	"#90b4a6",
	"#83a598",
	"#5cab95",
	"#b9d9cf",
	"#03d7b3",
	"#00b499",
	"#6fd0bd",
	"#1edacd",
	"#19b7b2",
	"#89cbce",
	"#4dcfdb",
	"#62a6ae",
	"#90e1ef",
	"#01aac0",
	"#48cae4",
	"#00ddff",
	"#6ac1db",
	"#00c3f9",
	"#99bbcd",
	"#149ccd",
	"#6da2c6",
	"#7bcaff",
	"#07b1fa",
	"#b4d4fb",
	"#629fdb",
	"#5aaaff",
	"#0798ff",
	"#4896ef",
	"#bbd1ff",
	"#9fb9f0",
	"#949aab",
	"#7b8ad5",
	"#8498fb",
	"#aaaffc",
	"#8187dc",
	"#ada7ff",
	"#aba3ca",
	"#d2c8f2",
	"#a681fb",
	"#b798f0",
	"#c3a4e1",
	"#ce8cf7",
	"#c97df9",
	"#e6a5f4",
	"#e47cfb",
	"#ffc6ff",
	"#f344ff",
	"#a882a7",
	"#c57fbf",
	"#ff4ded",
	"#f081de",
	"#fc69e6",
	"#dfa5ca",
	"#f646c1",
	"#ceb4c3",
	"#f27abe",
	"#ae8c99",
	"#ee91b6",
}


================================================
FILE: ui/gh.go
================================================
package ui

import (
	"log"

	ghapi "github.com/cli/go-gh/v2/pkg/api"
	ghgql "github.com/cli/shurcooL-graphql"
)

func getPRDataFromQuery(ghClient *ghapi.GraphQLClient, queryStr string, prCount int) ([]pr, error) {
	var query prSearchQuery

	variables := map[string]any{
		"query": ghgql.String(queryStr),
		"count": ghgql.Int(prCount),
	}
	err := ghClient.Query("PRQuery", &query, variables)
	if err != nil {
		return nil, err
	}
	var prs []pr //nolint:prealloc
	for _, edge := range query.Search.Edges {
		if edge.Node.Type != "PullRequest" {
			continue
		}
		prs = append(prs, edge.Node.pr)
	}
	return prs, nil
}

func getPRMetadata(ghClient *ghapi.GraphQLClient, repoOwner string, repoName string, prNumber int) (prDetails, error) {
	var query prDetailsQuery

	variables := map[string]any{
		"repositoryOwner":          ghgql.String(repoOwner),
		"repositoryName":           ghgql.String(repoName),
		"pullRequestNumber":        ghgql.Int(prNumber),
		"reviewRequestsCount":      ghgql.Int(reviewRequestsCount),
		"latestReviewsCount":       ghgql.Int(latestReviewsCount),
		"filesCount":               ghgql.Int(filesCount),
		"labelsCount":              ghgql.Int(labelsCount),
		"assigneesCount":           ghgql.Int(assigneesCount),
		"issuesCount":              ghgql.Int(issuesCount),
		"participantsCount":        ghgql.Int(participantsCount),
		"commentsCount":            ghgql.Int(commentsCount),
		"commitsCount":             ghgql.Int(commitsCount),
		"statusCheckContextsCount": ghgql.Int(statusCheckContextsCount),
	}
	err := ghClient.Query("PRTL", &query, variables)
	if err != nil {
		log.Printf("error: %s\n", err)
		return prDetails{}, err
	}
	return query.RepositoryOwner.Repository.PullRequest, nil
}

func getPRTLData(ghClient *ghapi.GraphQLClient, repoOwner string, repoName string, prNumber int, tlItemsCount int) ([]prTLItem, error) {
	var query prTLQuery

	variables := map[string]any{
		"repositoryOwner":    ghgql.String(repoOwner),
		"repositoryName":     ghgql.String(repoName),
		"pullRequestNumber":  ghgql.Int(prNumber),
		"timelineItemsCount": ghgql.Int(tlItemsCount),
	}
	err := ghClient.Query("PRTL", &query, variables)
	if err != nil {
		return nil, err
	}
	return query.RepositoryOwner.Repository.PullRequest.TimelineItems.Nodes, nil
}


================================================
FILE: ui/initial.go
================================================
package ui

import (
	"charm.land/bubbles/v2/list"
	"charm.land/lipgloss/v2"
	ghapi "github.com/cli/go-gh/v2/pkg/api"
)

const (
	fetchingPRsTitle = "fetching PRs..."
)

func InitialModel(ghClient *ghapi.GraphQLClient, config Config, mode Mode) Model {
	prListDel := newPRListItemDel()
	prTLListDel := newPRTLListItemDel()

	prDetailsCache := make(map[string]prDetails)
	prTLCache := make(map[string][]*prTLItemResult)

	prDetailsCurSectionCache := make(map[string]uint)

	m := Model{
		mode:                     mode,
		config:                   config,
		ghClient:                 ghClient,
		prsList:                  list.New(nil, prListDel, 0, 0),
		prTLList:                 list.New(nil, prTLListDel, 0, 0),
		prDetailsCache:           prDetailsCache,
		prTLCache:                prTLCache,
		showHelp:                 true,
		terminalDetails:          terminalDetails{width: widthBudgetDefault},
		prDetailsCurSectionCache: prDetailsCurSectionCache,
	}

	switch m.mode {
	case RepoMode:
		repoListItems := make([]list.Item, len(config.Repos))
		if mode == RepoMode {
			for i, issue := range config.Repos {
				repoListItems[i] = issue
			}
		}
		repoListDel := newRepoListItemDel()
		m.repoList = list.New(repoListItems, repoListDel, 0, 0)
		m.repoList.Title = "Repos"
		m.repoList.SetStatusBarItemName("repo", "repos")
		m.repoList.DisableQuitKeybindings()
		m.repoList.SetShowHelp(false)
		m.repoList.SetFilteringEnabled(false)
		m.repoList.Styles.Title = m.repoList.Styles.Title.Background(lipgloss.Color(repoListColor)).
			Foreground(lipgloss.Color(defaultBackgroundColor)).
			Bold(true)
		m.repoList.KeyMap.PrevPage.SetKeys("left", "h", "pgup")
		m.repoList.KeyMap.NextPage.SetKeys("right", "l", "pgdown")
	case QueryMode:
		m.activePane = prListView
	}

	m.prsList.Title = fetchingPRsTitle
	m.prsList.SetStatusBarItemName("PR", "PRs")
	m.prsList.DisableQuitKeybindings()
	m.prsList.SetShowHelp(false)
	m.prsList.SetFilteringEnabled(false)
	m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(fetchingColor)).
		Foreground(lipgloss.Color(defaultBackgroundColor)).
		Bold(true)
	m.prsList.KeyMap.PrevPage.SetKeys("left", "h", "pgup")
	m.prsList.KeyMap.NextPage.SetKeys("right", "l", "pgdown")

	m.prTLList.Title = "fetching timeline..."
	m.prTLList.SetStatusBarItemName("item", "items")
	m.prTLList.DisableQuitKeybindings()
	m.prTLList.SetShowHelp(false)
	m.prTLList.SetFilteringEnabled(false)
	m.prTLList.Styles.Title = m.prTLList.Styles.Title.Background(lipgloss.Color(prTLListColor)).
		Foreground(lipgloss.Color(defaultBackgroundColor)).
		Bold(true)
	m.prTLList.KeyMap.PrevPage.SetKeys("left", "h", "pgup")
	m.prTLList.KeyMap.NextPage.SetKeys("right", "l", "pgdown")

	return m
}


================================================
FILE: ui/model.go
================================================
package ui

import (
	"time"

	"charm.land/bubbles/v2/list"
	"charm.land/bubbles/v2/viewport"
	tea "charm.land/bubbletea/v2"
	"github.com/charmbracelet/glamour"
	ghapi "github.com/cli/go-gh/v2/pkg/api"
)

type Pane uint

const (
	repoListView Pane = iota
	prListView
	prDetailsView
	reviewPRListView
	prTLListView
	prTLItemDetailView
	helpView
)

type Mode uint

const (
	QueryMode Mode = iota
	RepoMode
)

type Model struct {
	mode                     Mode
	config                   Config
	ghClient                 *ghapi.GraphQLClient
	repoOwner                string
	repoName                 string
	repoList                 list.Model
	prsList                  list.Model
	prTLList                 list.Model
	prCache                  []*prResult
	prTLItemDetailVP         viewport.Model
	prTLItemDetailVPReady    bool
	prDetailsTitle           string
	prTLItemDetailTitle      string
	prDetailsVP              viewport.Model
	prDetailsVPReady         bool
	prDetailsCache           map[string]prDetails
	prTLCache                map[string][]*prTLItemResult
	message                  string
	helpVP                   viewport.Model
	helpVPReady              bool
	activePane               Pane
	lastPane                 Pane
	secondLastActivePane     Pane
	showHelp                 bool
	repoChosen               bool
	terminalDetails          terminalDetails
	mdRenderer               *glamour.TermRenderer
	prDetailsCurrentSection  uint
	prDetailsCurSectionCache map[string]uint
	prRevCurCmtNum           uint
}

func (m Model) Init() tea.Cmd {
	var cmds []tea.Cmd
	cmds = append(cmds, hideHelp(time.Minute*1))

	if m.mode == QueryMode {
		cmds = append(cmds, fetchPRSFromQuery(m.ghClient, *m.config.Query, m.config.PRCount))
	}

	return tea.Batch(cmds...)
}


================================================
FILE: ui/msgs.go
================================================
package ui

type hideHelpMsg struct{}

type repoChosenMsg struct {
	repo string
}

type prsFetchedMsg struct {
	prs []pr
	err error
}

type prMetadataFetchedMsg struct {
	repoOwner string
	repoName  string
	prNumber  int
	metadata  prDetails
	err       error
}

type reviewPRsFetchedMsg prsFetchedMsg

type authoredPRsFetchedMsg prsFetchedMsg

type prTLFetchedMsg struct {
	repoOwner string
	repoName  string
	prNumber  int
	prTLItems []prTLItem
	setItems  bool
	err       error
}

type urlOpenedinBrowserMsg struct {
	url string
	err error
}

type prDiffDoneMsg struct {
	err error
}

type prViewDoneMsg struct {
	err error
}


================================================
FILE: ui/navigation.go
================================================
package ui

import (
	"fmt"
	"strings"
)

const (
	maxCommentsForNavIndicator = 8
	disabledSectionMarker      = "◌"
	inactiveSectionMarker      = "◯"
	activeSectionMarker        = "●"
)

func (m *Model) setPRDetailsContent(prDetails prDetails, section PRDetailSection) {
	content := fmt.Sprintf(`# %s (%s/%s/pull/%d)
`, prDetails.PRTitle, prDetails.Repository.Owner.Login, prDetails.Repository.Name, prDetails.Number,
	)

	switch section {
	case PRMetadata:
		content += prDetails.Metadata()
	case PRDescription:
		content += prDetails.Description()
	case PRChecks:
		content += prDetails.Checks()
	case PRReferences:
		content += prDetails.References()
	case PRFilesChanged:
		content += prDetails.FilesChanged()
	case PRCommits:
		content += prDetails.CommitsList()
	case PRComments:
		content += prDetails.CommentsList()
	}

	glErr := true
	if m.mdRenderer != nil {
		contentGl, err := m.mdRenderer.Render(content)
		if err == nil {
			m.prDetailsVP.SetContent(contentGl)
			glErr = false
		}
	}
	if glErr {
		m.prDetailsVP.SetContent(content)
	}

	sections := make([]string, len(PRDetailsSectionList))
	for i := range PRDetailsSectionList {
		sections[i] = inactiveSectionMarker
	}

	if prDetails.Body == "" {
		sections[PRDescription] = disabledSectionMarker
	}
	// not foolproof, but should work in most cases
	// func (pr prDetails) Checks() will return with an appropriate message in
	// that case

	//nolint:staticcheck
	if !(len(prDetails.LastCommit.Nodes) > 0 && prDetails.LastCommit.Nodes[0].Commit.StatusCheckRollup != nil) {
		sections[PRChecks] = disabledSectionMarker
	}
	if len(prDetails.IssueReferences.Nodes) == 0 {
		sections[PRReferences] = disabledSectionMarker
	}
	if len(prDetails.Files.Nodes) == 0 {
		sections[PRFilesChanged] = disabledSectionMarker
	}
	if len(prDetails.Commits.Nodes) == 0 {
		sections[PRCommits] = disabledSectionMarker
	}
	if len(prDetails.Comments.Nodes) == 0 {
		sections[PRComments] = disabledSectionMarker
	}

	sections[section] = activeSectionMarker

	m.prDetailsTitle = fmt.Sprintf("PR Details%s", "  "+strings.Join(sections, " "))

	m.prDetailsVP.GotoTop()
}

func (m *Model) GoToPRDetailSection(section uint) {
	if m.prDetailsCurrentSection == section {
		return
	}
	pr, ok := m.prsList.SelectedItem().(*prResult)
	if !ok {
		return
	}

	prDetails, ok := m.prDetailsCache[fmt.Sprintf("%s/%s:%d", pr.pr.Repository.Owner.Login, pr.pr.Repository.Name, pr.pr.Number)]
	if !ok {
		return
	}
	switch section {
	case 1:
		if prDetails.Body == "" {
			return
		}
	case 2:
		//nolint:staticcheck
		if !(len(prDetails.LastCommit.Nodes) > 0 && prDetails.LastCommit.Nodes[0].Commit.StatusCheckRollup != nil) {
			return
		}
	case 3:
		if len(prDetails.IssueReferences.Nodes) == 0 {
			return
		}
	case 4:
		if len(prDetails.Files.Nodes) == 0 {
			return
		}
	case 5:
		if len(prDetails.Commits.Nodes) == 0 {
			return
		}
	case 6:
		if len(prDetails.Comments.Nodes) == 0 {
			return
		}
	}

	m.setPRDetailsContent(prDetails, PRDetailsSectionList[section])
	m.prDetailsCurrentSection = section
}

func (m *Model) setPRReviewCmt(tlItem *prTLItem, commentNum uint) {
	revCmts := tlItem.PullRequestReview.Comments.Nodes
	var sectionsStr string

	if len(revCmts) > maxCommentsForNavIndicator {
		sectionsStr = fmt.Sprintf("%d/%d", commentNum+1, len(revCmts))
	} else if len(revCmts) > 1 {
		sections := make([]string, len(revCmts))
		for i := range revCmts {
			sections[i] = inactiveSectionMarker
		}
		sections[commentNum] = activeSectionMarker
		sectionsStr = "  " + strings.Join(sections, " ")
	}

	var outdated string
	if revCmts[commentNum].Outdated {
		outdated = " `(outdated)`"
	}

	content := fmt.Sprintf("# from @%s\n## %s%s\n%s\n```diff\n%s\n```", tlItem.PullRequestReview.Author.Login, revCmts[commentNum].Path, outdated, revCmts[commentNum].Body, revCmts[commentNum].DiffHunk)

	glErr := true
	if m.mdRenderer != nil {
		contentGl, err := m.mdRenderer.Render(content)
		if err == nil {
			m.prTLItemDetailVP.SetContent(contentGl)
			glErr = false
		}
	}
	if glErr {
		m.prDetailsVP.SetContent(content)
	}

	m.prTLItemDetailTitle = fmt.Sprintf("Review Comments%s", sectionsStr)
	m.prTLItemDetailVP.GotoTop()
}


================================================
FILE: ui/pr_delegate.go
================================================
package ui

import (
	"charm.land/bubbles/v2/list"
	"charm.land/lipgloss/v2"
)

func newPRListItemDel() list.DefaultDelegate {
	d := list.NewDefaultDelegate()

	d.Styles.SelectedTitle = d.Styles.
		SelectedTitle.
		Foreground(lipgloss.Color(prListColor)).
		BorderLeftForeground(lipgloss.Color(prListColor))
	d.Styles.SelectedDesc = d.Styles.
		SelectedTitle

	return d
}


================================================
FILE: ui/prtl_delegate.go
================================================
package ui

import (
	"charm.land/bubbles/v2/list"
	"charm.land/lipgloss/v2"
)

func newPRTLListItemDel() list.DefaultDelegate {
	d := list.NewDefaultDelegate()

	d.Styles.SelectedTitle = d.Styles.
		SelectedTitle.
		Foreground(lipgloss.Color(prTLListColor)).
		BorderLeftForeground(lipgloss.Color(prTLListColor))
	d.Styles.SelectedDesc = d.Styles.
		SelectedTitle

	return d
}


================================================
FILE: ui/render_helpers.go
================================================
package ui

import (
	"fmt"
	"strings"

	humanize "github.com/dustin/go-humanize"
)

const (
	widthBudgetDefault    = 80
	responsiveWidthCutOff = 100
	wideScreenWidthFrac   = 0.9
)

func getPRTitle(pr *pr) string {
	if pr == nil {
		return ""
	}

	var reviewDecision string

	if pr.ReviewDecision != nil {
		switch *pr.ReviewDecision {
		case "CHANGES_REQUESTED":
			reviewDecision = "± "
		case "APPROVED":
			reviewDecision = "✅ "
		case "REVIEW_REQUIRED":
			reviewDecision = "🟡 "
		}
	}
	return fmt.Sprintf("%s#%2d %s", reviewDecision, pr.Number, pr.PRTitle)
}

func getPRDesc(pr *pr, mode Mode, terminalDetails terminalDetails) string {
	if pr == nil {
		return ""
	}

	var widthBudget int
	var widthFixed int
	var additions string
	var deletions string
	var reviews string
	var desc string

	switch mode {
	case RepoMode:
		widthFixed = 30
	case QueryMode:
		widthFixed = 16
	}

	if terminalDetails.width > responsiveWidthCutOff {
		widthBudget = getFracInt(terminalDetails.width, wideScreenWidthFrac) - widthFixed
	} else {
		widthBudget = terminalDetails.width - widthFixed
	}

	if widthBudget < 0 {
		widthBudget = widthBudgetDefault
	}

	if pr.Additions > 0 {
		additions = additionsStyle.Render(fmt.Sprintf("+%d", pr.Additions))
	}
	if pr.Deletions > 0 {
		deletions = deletionsStyle.Render(fmt.Sprintf("-%d", pr.Deletions))
	}

	if pr.Reviews.TotalCount > 0 {
		reviews = numReviewsStyle.Render(fmt.Sprintf("%dr", pr.Reviews.TotalCount))
	}

	switch mode {
	case RepoMode:
		updatedAt := dateStyle.Render(RightPadTrim("updated "+humanize.Time(pr.UpdatedAt), getFracInt(widthBudget, 0.3)))
		author := getDynamicStyle(pr.Author.Login).Render(RightPadTrim(pr.Author.Login, getFracInt(widthBudget, 0.7)))
		state := prStyle(pr.State).Render(pr.State)

		desc = fmt.Sprintf("%s%s%s%s%s%s", author, updatedAt, state, additions, deletions, reviews)

	case QueryMode:
		repoStr := fmt.Sprintf("%s/%s", pr.Repository.Owner.Login, pr.Repository.Name)
		updatedAt := dateStyle.Render(RightPadTrim("updated "+humanize.Time(pr.UpdatedAt), getFracInt(widthBudget, 0.3)))
		author := getDynamicStyle(pr.Author.Login).Render(RightPadTrim(pr.Author.Login, getFracInt(widthBudget, 0.4)))
		state := prStyle(pr.State).Render(pr.State)
		repo := getDynamicStyle(repoStr).Render(RightPadTrim(repoStr, getFracInt(widthBudget, 0.3)))

		desc = fmt.Sprintf("%s%s%s%s%s%s%s", author, repo, updatedAt, state, additions, deletions, reviews)
	}

	return desc
}

func getPRTLItemTitle(item *prTLItem) string {
	var title string
	var date string

	switch item.Type {
	case tlItemPRCommit:
		if item.PullRequestCommit.Commit.Author.User != nil {
			author := getDynamicStyle(item.PullRequestCommit.Commit.Author.User.Login).Render(item.PullRequestCommit.Commit.Author.User.Login)
			date = dateStyle.Render(humanize.Time(item.PullRequestCommit.Commit.CommittedDate))
			title = fmt.Sprintf("%spushed a commit%s", author, date)
		} else {
			title = fmt.Sprintf("%s pushed a commit", item.PullRequestCommit.Commit.Author.Name)
		}

	case tlItemHeadRefForcePushed:
		actor := getDynamicStyle(item.HeadRefForcePushed.Actor.Login).Render(item.HeadRefForcePushed.Actor.Login)
		beforeCommitHash := item.HeadRefForcePushed.BeforeCommit.AbbreviatedOid
		afterCommitHash := item.HeadRefForcePushed.AfterCommit.AbbreviatedOid
		date = dateStyle.Render(humanize.Time(item.HeadRefForcePushed.CreatedAt))
		title = fmt.Sprintf("%sforce pushed head ref from %s to %s%s", actor, beforeCommitHash, afterCommitHash, date)

	case tlItemPRReadyForReview:
		actor := getDynamicStyle(item.PullRequestReadyForReview.Actor.Login).Render(item.PullRequestReadyForReview.Actor.Login)
		title = fmt.Sprintf("%smarked PR as ready for review", actor)

	case tlItemPRReviewRequested:
		actor := getDynamicStyle(item.PullRequestReviewRequested.Actor.Login).Render(item.PullRequestReviewRequested.Actor.Login)
		reviewer := getDynamicStyle(item.PullRequestReviewRequested.RequestedReviewer.User.Login).Render(item.PullRequestReviewRequested.RequestedReviewer.User.Login)
		title = fmt.Sprintf("%srequested a review from %s", actor, reviewer)

	case tlItemPRReview:
		author := getDynamicStyle(item.PullRequestReview.Author.Login).Render(item.PullRequestReview.Author.Login)
		date = dateStyle.Render(humanize.Time(item.PullRequestReview.CreatedAt))
		var comments string
		var more string
		if item.PullRequestReview.Comments.TotalCount > 0 {
			more = " ⏎"
		}
		if item.PullRequestReview.Comments.TotalCount > 1 {
			comments = numCommentsStyle.Render(fmt.Sprintf("with %d comments", item.PullRequestReview.Comments.TotalCount))
		} else if item.PullRequestReview.Comments.TotalCount == 1 {
			comments = numCommentsStyle.Render("with 1 comment")
		}
		title = fmt.Sprintf("%sreviewed%s%s%s", author, comments, date, more)

	case tlItemMergedEvent:
		author := getDynamicStyle(item.MergedEvent.Actor.Login).Render(item.MergedEvent.Actor.Login)
		date = dateStyle.Render(humanize.Time(item.MergedEvent.CreatedAt))
		title = fmt.Sprintf("%smerged the PR%s", author, date)
	}
	return title
}

func getPRTLItemDesc(item *prTLItem) string {
	var desc string
	switch item.Type {
	case tlItemPRCommit:
		desc = fmt.Sprintf("📧 %s", item.PullRequestCommit.Commit.MessageHeadline)
	case tlItemHeadRefForcePushed:
		desc = fmt.Sprintf("💪 %s", item.HeadRefForcePushed.AfterCommit.MessageHeadline)
	case tlItemPRReadyForReview:
		desc = fmt.Sprintf("🚦%s", dateStyle.Render(humanize.Time(item.PullRequestReadyForReview.CreatedAt)))
	case tlItemPRReviewRequested:
		desc = fmt.Sprintf("🙏%s", dateStyle.Render(humanize.Time(item.PullRequestReviewRequested.CreatedAt)))
	case tlItemPRReview:
		reviewState := reviewStyle(item.PullRequestReview.State).Render(item.PullRequestReview.State)
		var comment string
		if item.PullRequestReview.Body != "" {
			comment = fmt.Sprintf("with comment: %s", strings.Split(item.PullRequestReview.Body, "\r")[0])
		}
		desc = fmt.Sprintf("🔎 %s%s", reviewState, comment)
	case tlItemMergedEvent:
		desc = fmt.Sprintf("🚀 message: %s", item.MergedEvent.MergeCommit.MessageHeadline)
	}
	return desc
}


================================================
FILE: ui/repo_delegate.go
================================================
package ui

import (
	"charm.land/bubbles/v2/list"
	"charm.land/lipgloss/v2"
)

func newRepoListItemDel() list.DefaultDelegate {
	d := list.NewDefaultDelegate()

	d.Styles.SelectedTitle = d.Styles.
		SelectedTitle.
		Foreground(lipgloss.Color(repoListColor)).
		BorderLeftForeground(lipgloss.Color(repoListColor))
	d.Styles.SelectedDesc = d.Styles.
		SelectedTitle

	return d
}


================================================
FILE: ui/styles.go
================================================
package ui

import (
	"hash/fnv"

	"charm.land/lipgloss/v2"
)

const (
	defaultBackgroundColor      = "#282828"
	repoListColor               = "#b8bb26"
	prListColor                 = "#fe8019"
	prTLListColor               = "#d3869b"
	prDetailsTitleColor         = "#fabd2f"
	revCmtListColor             = "#8ec07c"
	prOpenColor                 = "#fabd2f"
	prMergedColor               = "#b8bb26"
	prClosedColor               = "#928374"
	additionsColor              = "#8ec07c"
	deletionsColor              = "#fb4934"
	reviewCommentedColor        = "#83a598"
	reviewApprovedColor         = "#b8bb26"
	reviewChangesRequestedColor = "#fabd2f"
	reviewDismissedColor        = "#928374"
	dateColor                   = "#928374"
	repoColor                   = "#bdae93"
	numReviewsColor             = "#665c54"
	numCommentsColor            = "#83a598"
	footerColor                 = "#7c6f64"
	helpMsgColor                = "#83a598"
	helpViewTitleColor          = "#83a598"
	toolNameColor               = "#b8bb26"
	fetchingColor               = "#928374"
)

func getDynamicStyle(author string) lipgloss.Style {
	h := fnv.New32()
	h.Write([]byte(author))
	hash := h.Sum32()

	color := colors[int(hash)%len(colors)]

	st := lipgloss.NewStyle().
		PaddingRight(1).
		Foreground(lipgloss.Color(color))

	return st
}

var (
	baseStyle = lipgloss.NewStyle().
			Foreground(lipgloss.Color(defaultBackgroundColor))

	toolNameStyle = baseStyle.
			Align(lipgloss.Center).
			PaddingLeft(1).
			PaddingRight(1).
			Bold(true).
			Background(lipgloss.Color(toolNameColor))

	listStyle = baseStyle.
			PaddingTop(1).
			PaddingBottom(1).
			Foreground(lipgloss.Color(defaultBackgroundColor))

	viewPortStyle = lipgloss.NewStyle().
			PaddingTop(1).
			PaddingBottom(1)

	helpMsgStyle = baseStyle.
			PaddingLeft(2).
			Bold(true).
			Foreground(lipgloss.Color(helpMsgColor))

	dateStyle = lipgloss.NewStyle().
			PaddingLeft(1).
			Foreground(lipgloss.Color(dateColor))

	numReviewsStyle = lipgloss.NewStyle().
			PaddingLeft(1).
			Foreground(lipgloss.Color(numReviewsColor))

	numCommentsStyle = lipgloss.NewStyle().
				PaddingLeft(1).
				Foreground(lipgloss.Color(numCommentsColor))

	linesChangedStyle = lipgloss.NewStyle().
				PaddingLeft(1)

	additionsStyle = linesChangedStyle.
			PaddingLeft(2).
			Foreground(lipgloss.Color(additionsColor))

	deletionsStyle = linesChangedStyle.
			Foreground(lipgloss.Color(deletionsColor))

	prStyle = func(state string) lipgloss.Style {
		st := lipgloss.NewStyle().
			PaddingLeft(1).
			PaddingRight(1).
			Width(8).
			Bold(true).
			Align(lipgloss.Center).
			Foreground(lipgloss.Color(defaultBackgroundColor))

		var bgColor string
		switch state {
		case prStateOpen:
			bgColor = prOpenColor
		case prStateMerged:
			bgColor = prMergedColor
		default:
			bgColor = prClosedColor
		}
		return st.Background(lipgloss.Color(bgColor))
	}

	reviewStyle = func(state string) lipgloss.Style {
		st := lipgloss.NewStyle().
			PaddingRight(1).
			Bold(true).
			Align(lipgloss.Center)

		var bgColor string
		switch state {
		case reviewCommented:
			bgColor = reviewCommentedColor
		case reviewApproved:
			bgColor = reviewApprovedColor
		case reviewChangesRequested:
			bgColor = reviewChangesRequestedColor
		default:
			bgColor = reviewDismissedColor
		}
		return st.Foreground(lipgloss.Color(bgColor))
	}

	footerStyle = lipgloss.NewStyle().
			Foreground(lipgloss.Color(defaultBackgroundColor)).
			Background(lipgloss.Color(footerColor))

	titleStyle = lipgloss.NewStyle().
			PaddingLeft(1).
			PaddingRight(1).
			Bold(true).
			Foreground(lipgloss.Color(defaultBackgroundColor))

	helpVPTitleStyle = titleStyle.
				Background(lipgloss.Color(helpViewTitleColor))

	prDetailsTitleStyle = titleStyle.
				Background(lipgloss.Color(prDetailsTitleColor))
)


================================================
FILE: ui/types.go
================================================
package ui

import (
	"fmt"
	"strings"
	"time"

	"github.com/dustin/go-humanize"
)

const (
	prStateOpen                 = "OPEN"
	prStateMerged               = "MERGED"
	prStateClosed               = "CLOSED"
	prRevDecChangesReq          = "CHANGES_REQUESTED"
	prRevDecApproved            = "APPROVED"
	prRevDecRevReq              = "REVIEW_REQUIRED"
	tlItemPRCommit              = "PullRequestCommit"
	tlItemPRReadyForReview      = "ReadyForReviewEvent"
	tlItemPRReviewRequested     = "ReviewRequestedEvent"
	tlItemPRReview              = "PullRequestReview"
	tlItemMergedEvent           = "MergedEvent"
	tlItemHeadRefForcePushed    = "HeadRefForcePushedEvent"
	reviewPending               = "PENDING"
	reviewCommented             = "COMMENTED"
	reviewApproved              = "APPROVED"
	reviewChangesRequested      = "CHANGES_REQUESTED"
	reviewDismissed             = "DISMISSED"
	checkStatusStateCompleted   = "COMPLETED"
	checkRunType                = "CheckRun"
	statusContextType           = "StatusContext"
	checkConclusionStateSuccess = "SUCCESS"
	checkConclusionStateFailure = "FAILURE"
	checkConclusionStateError   = "ERROR"
	statusStateSuccess          = "SUCCESS"
	statusStateFailure          = "FAILURE"
	statusStateError            = "ERROR"
	requestedReviewerUser       = "User"
	prDetailsMetadataKeyPadding = 30
	checkNamePadding            = 40
	statusConclusionPadding     = 16
	reviewRequestsCount         = 20
	latestReviewsCount          = 30
	filesCount                  = 50
	labelsCount                 = 10
	assigneesCount              = 10
	issuesCount                 = 10
	participantsCount           = 30
	commentsCount               = 10
	commitsCount                = 30
	statusCheckContextsCount    = 50
	timeFormat                  = "2006/01/02 15:04"
	mergeableConflicting        = "CONFLICTING"
	noChecksHeader              = "## No Checks"
)

type terminalDetails struct {
	width int
}

type SourceConfig struct {
	DiffPager *string `yaml:"diff-pager"`
	PRCount   *int    `yaml:"pr-count"`
	Sources   *[]struct {
		Owner string `yaml:"owner"`
		Repos []struct {
			Name string `yaml:"name"`
		} `yaml:"repos"`
	} `yaml:"sources"`
	Query *string `yaml:"query"`
}

type Repo struct {
	Owner string
	Name  string
}

type Config struct {
	PRCount int
	Repos   []Repo
	Query   *string
}

type prResult struct {
	pr          *pr
	title       string
	description string
	identifier  string
}

type prTLItemResult struct {
	item        *prTLItem
	title       string
	description string
}

type pr struct {
	Number     int
	PRTitle    string `graphql:"prTitle: title"`
	Repository struct {
		Owner struct {
			Login string
		}
		Name string
	}
	State          string
	Mergeable      string
	IsDraft        bool
	ReviewDecision *string
	CreatedAt      time.Time
	UpdatedAt      time.Time
	ClosedAt       *time.Time
	MergedAt       *time.Time
	LastEditedAt   *time.Time
	Author         struct {
		Login string
	}
	URL       string
	Additions int
	Deletions int
	Reviews   struct {
		TotalCount int
	}
}

type prDetails struct {
	Number     int
	PRTitle    string `graphql:"prTitle: title"`
	Repository struct {
		Owner struct {
			Login string
		}
		Name string
	}
	State          string
	Mergeable      string
	IsDraft        bool
	ReviewDecision *string
	CreatedAt      time.Time
	UpdatedAt      time.Time
	ClosedAt       *time.Time
	MergedAt       *time.Time
	LastEditedAt   *time.Time
	Author         struct {
		Login string
	}
	Additions      int
	Deletions      int
	ReviewRequests *struct {
		Nodes []struct {
			RequestedReviewer *struct {
				Type string `graphql:"type: __typename"`
				User struct {
					Login string
				} `graphql:"... on User "`
			}
		}
	} `graphql:"reviewRequests (first:$reviewRequestsCount)"`
	LatestReviews struct {
		Nodes []struct {
			Author struct {
				Login string
			}
			State string
		}
	} `graphql:"latestReviews (last: $latestReviewsCount)"`
	Body  string
	Files struct {
		Nodes []struct {
			Path      string
			Additions int
			Deletions int
		}
	} `graphql:"files (first: $filesCount)"`
	Labels struct {
		Nodes []struct {
			Name string
		}
	} `graphql:"labels (first: $labelsCount)"`
	Assignees struct {
		Nodes []struct {
			Login string
		}
	} `graphql:"assignees (first: $assigneesCount)"`
	IssueReferences struct {
		Nodes []struct {
			Number int
			Title  string
			URL    string
		}
	} `graphql:"closingIssuesReferences (first: $issuesCount)"`
	Participants struct {
		Nodes []struct {
			Login string
		}
	} `graphql:"participants (first: $participantsCount)"`
	Comments struct {
		TotalCount int
		Nodes      []struct {
			Body      string
			UpdatedAt time.Time
			Author    struct {
				Login string
			}
		}
	} `graphql:"comments (first: $commentsCount)"`
	Commits struct {
		TotalCount int
		Nodes      []struct {
			Commit struct {
				AbbreviatedOid  string
				MessageHeadline string
				AuthoredDate    time.Time
				Author          struct {
					Name string
				}
			}
		}
	} `graphql:"commits (last: $commitsCount)"`
	MergedBy *struct {
		Login string
	}
	Milestone *struct {
		Title string
	}
	LastCommit struct {
		Nodes []struct {
			Commit struct {
				AbbreviatedOid    string
				StatusCheckRollup *struct {
					Contexts struct {
						Nodes []struct {
							Type     string `graphql:"type: __typename"`
							CheckRun struct {
								Status     string
								Conclusion *string
								Name       string
							} `graphql:"... on CheckRun"`
							StatusContext struct {
								State   string
								Context string
							} `graphql:"... on StatusContext"`
						}
					} `graphql:"contexts (first: $statusCheckContextsCount) "`
					State string
				}
			}
		}
	} `graphql:"lastCommit: commits(last: 1)"`
}

type PRDetailSection uint

const (
	PRMetadata PRDetailSection = iota
	PRDescription
	PRChecks
	PRReferences
	PRFilesChanged
	PRCommits
	PRComments
)

var PRDetailsSectionList = []PRDetailSection{
	PRMetadata,
	PRDescription,
	PRChecks,
	PRReferences,
	PRFilesChanged,
	PRCommits,
	PRComments,
}

type prReviewComment struct {
	CreatedAt time.Time
	Body      string
	Outdated  bool
	DiffHunk  string
	Path      string
	URL       string
}

type prSearchQuery struct {
	Search struct {
		Edges []struct {
			Node struct {
				Type string `graphql:"type: __typename"`
				pr   `graphql:"... on PullRequest"`
			}
		}
	} `graphql:"search(query: $query, type: ISSUE, first: $count)"`
}

type prDetailsQuery struct {
	RepositoryOwner struct {
		Repository struct {
			PullRequest prDetails `graphql:"pullRequest(number: $pullRequestNumber)"`
		} `graphql:"repository(name: $repositoryName)"`
	} `graphql:"repositoryOwner(login: $repositoryOwner)"`
}

type prTLItem struct {
	Type              string `graphql:"type: __typename"`
	PullRequestCommit struct {
		URL    string
		Commit struct {
			CommittedDate   time.Time
			MessageHeadline string
			Author          struct {
				Name string
				User *struct {
					Login string
				}
			}
		}
	} `graphql:"... on PullRequestCommit"`
	HeadRefForcePushed struct {
		CreatedAt time.Time
		Actor     struct {
			Login string
		}
		BeforeCommit struct {
			AbbreviatedOid string
		}
		AfterCommit struct {
			AbbreviatedOid  string
			URL             string
			MessageHeadline string
		}
	} `graphql:"... on HeadRefForcePushedEvent"`
	PullRequestReadyForReview struct {
		CreatedAt time.Time
		Actor     struct {
			Login string
		}
	} `graphql:"... on ReadyForReviewEvent"`
	PullRequestReviewRequested struct {
		CreatedAt time.Time
		Actor     struct {
			Login string
		}
		RequestedReviewer struct {
			User struct {
				Login string
			} `graphql:"... on User"`
		}
	} `graphql:"... on ReviewRequestedEvent"`
	PullRequestReview struct {
		URL       string
		CreatedAt time.Time
		State     string
		Body      string
		Comments  struct {
			TotalCount int
			Nodes      []prReviewComment
		} `graphql:"comments(first: 100)"`
		Author struct {
			Login string
		}
	} `graphql:"... on PullRequestReview"`
	MergedEvent struct {
		CreatedAt   time.Time
		URL         string
		MergeCommit struct {
			MessageHeadline string
		} `graphql:"mergeCommit: commit"`
		Actor struct {
			Login string
		}
	} `graphql:"... on MergedEvent"`
}

type prTLQuery struct {
	RepositoryOwner struct {
		Repository struct {
			PullRequest struct {
				TimelineItems struct {
					Nodes []prTLItem
				} `graphql:"timelineItems(last: $timelineItemsCount, itemTypes: [PULL_REQUEST_COMMIT, READY_FOR_REVIEW_EVENT, REVIEW_REQUESTED_EVENT, MERGED_EVENT, PULL_REQUEST_REVIEW, HEAD_REF_FORCE_PUSHED_EVENT])"`
			} `graphql:"pullRequest(number: $pullRequestNumber)"`
		} `graphql:"repository(name: $repositoryName)"`
	} `graphql:"repositoryOwner(login: $repositoryOwner)"`
}

func (pr prDetails) Metadata() string {
	var metadata []string

	metadata = append(metadata, fmt.Sprintf("- %s *%s*",
		RightPadTrim("State", prDetailsMetadataKeyPadding),
		pr.State,
	))

	metadata = append(metadata, fmt.Sprintf("- %s `@%s`",
		RightPadTrim("Author", prDetailsMetadataKeyPadding),
		pr.Author.Login,
	))

	if len(pr.Assignees.Nodes) > 0 {
		assignees := make([]string, len(pr.Assignees.Nodes))
		for i, l := range pr.Assignees.Nodes {
			assignees[i] = fmt.Sprintf("`@%s`", l.Login)
		}
		metadata = append(metadata, fmt.Sprintf("- %s %s",
			RightPadTrim("Assignees", prDetailsMetadataKeyPadding),
			strings.Join(assignees, ", "),
		))
	}

	if len(pr.Participants.Nodes) > 0 {
		participants := make([]string, len(pr.Participants.Nodes))
		for i, l := range pr.Participants.Nodes {
			participants[i] = fmt.Sprintf("`@%s`", l.Login)
		}
		metadata = append(metadata, fmt.Sprintf("- %s %s",
			RightPadTrim("Participants", prDetailsMetadataKeyPadding),
			strings.Join(participants, ", "),
		))
	}

	if pr.ReviewRequests != nil && len(pr.ReviewRequests.Nodes) > 0 {
		var requested []string
		for _, r := range pr.ReviewRequests.Nodes {
			if r.RequestedReviewer.Type != requestedReviewerUser {
				continue
			}
			requested = append(requested, fmt.Sprintf("`@%s`", r.RequestedReviewer.User.Login))
		}

		if len(requested) > 0 {
			metadata = append(metadata, fmt.Sprintf("- %s %s",
				RightPadTrim("Review requested from", prDetailsMetadataKeyPadding),
				strings.Join(requested, ", "),
			))
		}
	}

	metadata = append(metadata, fmt.Sprintf("- %s %s (%s)",
		RightPadTrim("Created at", prDetailsMetadataKeyPadding),
		pr.CreatedAt.Format(timeFormat),
		humanize.Time(pr.CreatedAt),
	))
	if pr.LastEditedAt != nil && *pr.LastEditedAt != pr.CreatedAt {
		metadata = append(metadata, fmt.Sprintf("- %s %s (%s)",
			RightPadTrim("Last edited at", prDetailsMetadataKeyPadding),
			pr.LastEditedAt.Format(timeFormat),
			humanize.Time(*pr.LastEditedAt),
		))
	}

	switch pr.State {
	case prStateClosed:
		if pr.ClosedAt != nil {
			metadata = append(metadata, fmt.Sprintf("- %s %s (%s)",
				RightPadTrim("Closed at", prDetailsMetadataKeyPadding),
				pr.ClosedAt.Format(timeFormat),
				humanize.Time(*pr.ClosedAt),
			))
		}
	case prStateMerged:
		metadata = append(metadata, fmt.Sprintf("- %s %s (%s) by `@%s`",
			RightPadTrim("Merged at", prDetailsMetadataKeyPadding),
			pr.MergedAt.Format(timeFormat),
			humanize.Time(*pr.MergedAt),
			pr.MergedBy.Login,
		))
	}

	if len(pr.Labels.Nodes) > 0 {
		labels := make([]string, len(pr.Labels.Nodes))
		for i, l := range pr.Labels.Nodes {
			labels[i] = fmt.Sprintf("*%s*", l.Name)
		}
		metadata = append(metadata, fmt.Sprintf("- %s %s",
			RightPadTrim("Labels", prDetailsMetadataKeyPadding),
			strings.Join(labels, " "),
		))
	}

	if pr.Commits.TotalCount > 0 {
		metadata = append(metadata, fmt.Sprintf("- %s %d",
			RightPadTrim("Commits", prDetailsMetadataKeyPadding),
			pr.Commits.TotalCount,
		))
	}

	if pr.Comments.TotalCount > 0 {
		metadata = append(metadata, fmt.Sprintf("- %s %d",
			RightPadTrim("Comments", prDetailsMetadataKeyPadding),
			pr.Comments.TotalCount,
		))
	}

	if pr.IsDraft {
		metadata = append(metadata, fmt.Sprintf("- %s `true`",
			RightPadTrim("Is draft",
				prDetailsMetadataKeyPadding),
		))
	}

	if pr.Mergeable == mergeableConflicting {
		metadata = append(metadata, fmt.Sprintf("- %s `true`", RightPadTrim("Has conflicts",
			prDetailsMetadataKeyPadding),
		))
	}

	if pr.Milestone != nil {
		metadata = append(metadata, fmt.Sprintf("- %s %s", RightPadTrim("Milestone",
			prDetailsMetadataKeyPadding),
			pr.Milestone.Title,
		))
	}

	if len(pr.LatestReviews.Nodes) > 0 {
		reviews := make([]string, len(pr.LatestReviews.Nodes))

		for i, r := range pr.LatestReviews.Nodes {
			var state string
			switch r.State {
			case reviewPending:
				state = "🟡"
			case reviewCommented:
				state = "💬"
			case reviewChangesRequested:
				state = "🔄"
			case reviewApproved:
				state = "✅"
			case reviewDismissed:
				state = "❌"
			}
			reviews[i] = fmt.Sprintf("`@%s` %s", r.Author.Login, state)
		}

		metadata = append(metadata, "\n---\n")

		metadata = append(metadata, fmt.Sprintf("- %s %s",
			RightPadTrim("Reviewed by", prDetailsMetadataKeyPadding),
			strings.Join(reviews, ", "),
		))
	}

	return fmt.Sprintf(`
## Metadata

%s`, strings.Join(metadata, "\n"))
}

func (pr prDetails) Description() string {
	return fmt.Sprintf(`
## Description

%s`, pr.Body)
}

func (pr prDetails) Checks() string {
	if len(pr.LastCommit.Nodes) == 0 {
		return noChecksHeader
	}
	if pr.LastCommit.Nodes[0].Commit.StatusCheckRollup == nil {
		return noChecksHeader
	}
	if len(pr.LastCommit.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes) == 0 {
		return noChecksHeader
	}

	var checks []string
	for _, n := range pr.LastCommit.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes {
		switch n.Type {
		case checkRunType:
			checkName := RightPadTrim(n.CheckRun.Name, checkNamePadding)
			if n.CheckRun.Conclusion != nil {
				var conclusionMarker string
				switch *n.CheckRun.Conclusion {
				case checkConclusionStateSuccess:
					conclusionMarker = " ✅"
				case checkConclusionStateFailure, checkConclusionStateError:
					conclusionMarker = " ❌"
				}
				checks = append(checks, fmt.Sprintf("- %s %s%s",
					checkName,
					RightPadTrim(fmt.Sprintf("`%s`", *n.CheckRun.Conclusion), statusConclusionPadding),
					conclusionMarker,
				))
			} else {
				checks = append(checks, fmt.Sprintf("- %s %s", checkName, n.CheckRun.Status))
			}
		case statusContextType:
			var stateMarker string
			switch n.StatusContext.State {
			case statusStateSuccess:
				stateMarker = " ✅"
			case statusStateFailure, statusStateError:
				stateMarker = " ❌"
			}
			checks = append(checks, fmt.Sprintf("- %s %s%s",
				RightPadTrim(n.StatusContext.Context, checkNamePadding),
				RightPadTrim(fmt.Sprintf("`%s`", n.StatusContext.State), statusConclusionPadding),
				stateMarker,
			))
		}
	}

	if len(checks) == 0 {
		return noChecksHeader
	}

	return fmt.Sprintf(`
## Checks

%s **%s**

%s`,
		RightPadTrim("> Status of latest commit", checkNamePadding+2),
		pr.LastCommit.Nodes[0].Commit.StatusCheckRollup.State,
		strings.Join(checks, "\n"))
}

func (pr prDetails) References() string {
	issues := make([]string, len(pr.IssueReferences.Nodes))
	for i, iss := range pr.IssueReferences.Nodes {
		issues[i] = fmt.Sprintf("- `#%d`: %s (%s)", iss.Number, iss.Title, iss.URL)
	}
	return fmt.Sprintf(`
## Referenced by

%s`, strings.Join(issues, "\n"))
}

func (pr prDetails) FilesChanged() string {
	fc := make([]string, len(pr.Files.Nodes))
	for i, f := range pr.Files.Nodes {
		var additions string
		var deletions string

		if f.Additions > 0 {
			additions = fmt.Sprintf(" `+%d`", f.Additions)
		}

		if f.Deletions > 0 {
			deletions = fmt.Sprintf(" `-%d`", f.Deletions)
		}

		fc[i] = fmt.Sprintf("- %s%s%s", f.Path, additions, deletions)
	}
	return fmt.Sprintf(`
## Files changed

%s`, strings.Join(fc, "\n"))
}

func (pr prDetails) CommitsList() string {
	var commitsStr string

	commits := make([]string, len(pr.Commits.Nodes))
	for i, c := range pr.Commits.Nodes {
		hash := c.Commit.AbbreviatedOid

		commits[i] = fmt.Sprintf("- `%s`: %s **(%s)** `<%s>`",
			hash,
			c.Commit.MessageHeadline,
			humanize.Time(c.Commit.AuthoredDate),
			c.Commit.Author.Name,
		)
	}

	var commitsNumStr string
	if len(pr.Commits.Nodes) < pr.Commits.TotalCount {
		commitsNumStr = fmt.Sprintf(" (last %d out of %d)", len(pr.Commits.Nodes), pr.Commits.TotalCount)
	}

	commitsStr = fmt.Sprintf(`
## Commits%s

%s
`, commitsNumStr, strings.Join(commits, "\n"))

	return commitsStr
}

func (pr prDetails) CommentsList() string {
	comments := make([]string, len(pr.Comments.Nodes))
	for i, c := range pr.Comments.Nodes {
		comments[i] = fmt.Sprintf("`@%s` (%s):\n\n%s", c.Author.Login, humanize.Time(c.UpdatedAt), c.Body)
	}

	var commentsNumStr string
	if len(pr.Comments.Nodes) < pr.Comments.TotalCount {
		commentsNumStr = fmt.Sprintf(" (first %d out of %d)", len(pr.Comments.Nodes), pr.Comments.TotalCount)
	}

	return fmt.Sprintf(`
## Comments%s

%s
`, commentsNumStr, strings.Join(comments, "\n\n▬▬▬▬▬▬\n\n"))
}

func (repo Repo) Title() string {
	return repo.Name
}

func (repo Repo) Description() string {
	return repo.Owner
}

func (repo Repo) FilterValue() string {
	return fmt.Sprintf("%s:::%s", repo.Owner, repo.Name)
}

func (prRes prResult) Title() string {
	return prRes.title
}

func (prRes prResult) Description() string {
	return prRes.description
}

func (prRes prResult) FilterValue() string {
	return fmt.Sprintf("%d", prRes.pr.Number)
}

func (ir prTLItemResult) Title() string {
	return ir.title
}

func (ir prTLItemResult) Description() string {
	return ir.description
}

func (ir prTLItemResult) FilterValue() string {
	return ir.title
}


================================================
FILE: ui/ui.go
================================================
package ui

import (
	"fmt"
	"os"

	tea "charm.land/bubbletea/v2"
	ghapi "github.com/cli/go-gh/v2/pkg/api"
)

func RenderUI(ghClient *ghapi.GraphQLClient, config Config, mode Mode) error {
	if len(os.Getenv("DEBUG")) > 0 {
		f, err := tea.LogToFile("debug.log", "debug")
		if err != nil {
			fmt.Println("fatal:", err)
			os.Exit(1)
		}
		defer f.Close()
	}
	p := tea.NewProgram(InitialModel(ghClient, config, mode))
	_, err := p.Run()
	return err
}


================================================
FILE: ui/update.go
================================================
package ui

import (
	_ "embed"
	"errors"
	"fmt"
	"strings"

	"charm.land/bubbles/v2/list"
	"charm.land/bubbles/v2/viewport"
	tea "charm.land/bubbletea/v2"
	"charm.land/lipgloss/v2"
	"github.com/dhth/prs/internal/utils"
)

const (
	viewPortMoveLineCount  = 5
	couldntGetPRDetailsMsg = "Couldn't get repo/pr details. Inform @dhth on Github."
)

var (
	//go:embed assets/help.md
	helpStr string

	ErrPRDetailsNotCached = errors.New("PR details were not saved")
)

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmd tea.Cmd
	var cmds []tea.Cmd
	m.message = ""

	switch msg := msg.(type) {
	case tea.KeyPressMsg:
		switch msg.String() {
		case "Q":
			return m, tea.Quit
		case "ctrl+c", "q", "esc":
			switch m.activePane {
			case repoListView:
				if !m.repoChosen {
					return m, tea.Quit
				}
				m.activePane = m.lastPane
			case helpView:
				m.activePane = m.lastPane
			case prTLItemDetailView:
				m.prTLItemDetailVP.GotoTop()
				m.activePane = prTLListView
			case prTLListView:
				m.prTLList.ResetSelected()
				m.activePane = prListView
			case prDetailsView:
				if m.lastPane == m.activePane {
					m.activePane = m.secondLastActivePane
					m.lastPane = prDetailsView
					break
				}
				m.activePane = m.lastPane
				m.lastPane = prDetailsView
			case prListView:
				if m.mode == RepoMode {
					m.activePane = repoListView
					m.repoChosen = false
					break
				}
				return m, tea.Quit
			default:
				return m, tea.Quit
			}

		case "ctrl+r":
			switch m.activePane {
			case prListView:

				switch m.mode {
				case RepoMode:
					cmds = append(cmds, fetchPRSForRepo(m.ghClient, m.repoOwner, m.repoName, m.config.PRCount))
				case QueryMode:
					cmds = append(cmds, fetchPRSFromQuery(m.ghClient, *m.config.Query, m.config.PRCount))
				}
				m.prsList.Title = fetchingPRsTitle
				m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(fetchingColor))

			case prTLListView:
				pr, ok := m.prsList.SelectedItem().(*prResult)
				if !ok {
					break
				}

				repoOwner := pr.pr.Repository.Owner.Login
				repoName := pr.pr.Repository.Name
				prNumber := pr.pr.Number
				cmds = append(cmds, fetchPRTLItems(m.ghClient, repoOwner, repoName, prNumber, 100, true))
				m.prTLList.Title = "fetching timeline..."
				m.prTLList.Styles.Title = m.prTLList.Styles.Title.Background(lipgloss.Color(fetchingColor))
			}
		case "1":
			if m.activePane != prTLListView && m.activePane != prTLItemDetailView && m.activePane != prDetailsView {
				break
			}

			switch m.activePane {
			case prDetailsView:
				m.GoToPRDetailSection(0)
			default:
				m.activePane = prListView
			}

		case "enter":
			switch m.activePane {
			case prListView:
				setTlCmd, ok := m.setTL()
				if !ok {
					m.message = couldntGetPRDetailsMsg
				} else {
					if setTlCmd != nil {
						cmds = append(cmds, setTlCmd)
					}
				}
			case prTLListView:
				item, ok := m.prTLList.SelectedItem().(*prTLItemResult)
				if !ok {
					break
				}

				if item.item.Type != tlItemPRReview {
					break
				}

				if len(item.item.PullRequestReview.Comments.Nodes) == 0 {
					break
				}

				m.setPRReviewCmt(item.item, 0)
				m.prRevCurCmtNum = 0
				m.activePane = prTLItemDetailView

			case repoListView:
				selected := m.repoList.SelectedItem()
				if selected != nil {
					cmds = append(cmds, chooseRepo(selected.FilterValue()))
				}
			}
		case "2":
			if m.activePane != prListView && m.activePane != prTLItemDetailView && m.activePane != prDetailsView {
				break
			}

			switch m.activePane {
			case prDetailsView:
				m.GoToPRDetailSection(1)
			default:
				setTlCmd, ok := m.setTL()
				if !ok {
					m.message = "Could't get repo/pr details. Inform @dhth on github."
					break
				}

				if setTlCmd != nil {
					cmds = append(cmds, setTlCmd)
				}
			}

		case "3":
			if m.activePane != prTLListView && m.activePane != prDetailsView {
				break
			}

			switch m.activePane {
			case prDetailsView:
				m.GoToPRDetailSection(2)
			default:
				tlItem, ok := m.prTLList.SelectedItem().(*prTLItemResult)
				if !ok {
					break
				}

				if tlItem.item.Type != tlItemPRReview {
					break
				}

				if len(tlItem.item.PullRequestReview.Comments.Nodes) == 0 {
					break
				}

				m.setPRReviewCmt(tlItem.item, 0)
				m.activePane = prTLItemDetailView
			}

		case "4":
			if m.activePane != prDetailsView {
				break
			}

			m.GoToPRDetailSection(3)

		case "5":
			if m.activePane != prDetailsView {
				break
			}

			m.GoToPRDetailSection(4)

		case "6":
			if m.activePane != prDetailsView {
				break
			}

			m.GoToPRDetailSection(5)

		case "j", "down":
			if m.activePane != prTLItemDetailView && m.activePane != helpView && m.activePane != prDetailsView {
				break
			}

			switch m.activePane {
			case prTLItemDetailView:
				if m.prTLItemDetailVP.AtBottom() {
					break
				}
				m.prTLItemDetailVP.ScrollDown(viewPortMoveLineCount)
			case prDetailsView:
				if m.prDetailsVP.AtBottom() {
					break
				}
				m.prDetailsVP.ScrollUp(viewPortMoveLineCount)
			case helpView:
				if m.helpVP.AtBottom() {
					break
				}
				m.helpVP.ScrollDown(viewPortMoveLineCount)
			}

		case "k", "up":
			if m.activePane != prTLItemDetailView && m.activePane != helpView && m.activePane != prDetailsView {
				break
			}

			switch m.activePane {
			case prTLItemDetailView:
				if m.prTLItemDetailVP.AtTop() {
					break
				}
				m.prTLItemDetailVP.ScrollUp(viewPortMoveLineCount)
			case prDetailsView:
				if m.prDetailsVP.AtTop() {
					break
				}
				m.prDetailsVP.ScrollUp(viewPortMoveLineCount)
			case helpView:
				if m.helpVP.AtTop() {
					break
				}
				m.helpVP.ScrollUp(viewPortMoveLineCount)
			}

		case "tab", "shift+tab":
			if m.activePane == helpView || m.activePane == prDetailsView {
				break
			}

			if m.activePane == prListView {
				setTlCmd, ok := m.setTL()
				if !ok {
					m.message = "Could't get repo/pr details. Inform @dhth on github."
				} else {
					if setTlCmd != nil {
						cmds = append(cmds, setTlCmd)
					}
				}
			} else {
				m.activePane = prListView
			}
		case "ctrl+s":
			if m.mode == RepoMode {
				if m.activePane != repoListView {
					m.lastPane = m.activePane
					m.activePane = repoListView
				} else {
					m.activePane = m.lastPane
				}
			}

		case "ctrl+b":
			switch m.activePane {
			case prListView, prDetailsView:
				pr, ok := m.prsList.SelectedItem().(*prResult)
				if !ok {
					break
				}

				cmds = append(cmds, openURLInBrowser(pr.pr.URL))
			case prTLListView, prTLItemDetailView:
				item, ok := m.prTLList.SelectedItem().(*prTLItemResult)
				if !ok {
					break
				}

				switch item.item.Type {
				case tlItemPRCommit:
					cmds = append(cmds, openURLInBrowser(item.item.PullRequestCommit.URL))
				case tlItemHeadRefForcePushed:
					cmds = append(cmds, openURLInBrowser(item.item.HeadRefForcePushed.AfterCommit.URL))
				case tlItemPRReview:
					cmds = append(cmds, openURLInBrowser(item.item.PullRequestReview.URL))
				case tlItemMergedEvent:
					cmds = append(cmds, openURLInBrowser(item.item.MergedEvent.URL))
				}
			}

		case "ctrl+d":
			if m.activePane != prListView && m.activePane != prTLListView {
				break
			}

			pr, ok := m.prsList.SelectedItem().(*prResult)
			if !ok {
				break
			}

			cmds = append(cmds, showDiff(pr.pr.Repository.Owner.Login,
				pr.pr.Repository.Name,
				pr.pr.Number))

		case "ctrl+v":
			if m.activePane == helpView {
				break
			}
			pr, ok := m.prsList.SelectedItem().(*prResult)
			if !ok {
				break
			}

			cmds = append(cmds, showPR(pr.pr.Repository.Owner.Login,
				pr.pr.Repository.Name,
				pr.pr.Number))

		case "g":
			switch m.activePane {
			case prTLItemDetailView:
				m.prTLItemDetailVP.GotoTop()
			case prDetailsView:
				m.prDetailsVP.GotoTop()
			case helpView:
				m.helpVP.GotoTop()
			}
		case "G":
			switch m.activePane {
			case prTLItemDetailView:
				m.prTLItemDetailVP.GotoBottom()
			case prDetailsView:
				m.prDetailsVP.GotoBottom()
			case helpView:
				m.helpVP.GotoBottom()
			}

		case "K", "[":
			if m.activePane != prDetailsView {
				break
			}

			m.prsList.CursorUp()
			prRes, ok := m.prsList.SelectedItem().(*prResult)
			if !ok {
				break
			}

			prDetails, ok := m.prDetailsCache[prRes.identifier]
			if !ok {
				break
			}

			var section uint
			lastSection, ok := m.prDetailsCurSectionCache[prRes.identifier]
			if ok {
				section = lastSection
			} else {
				section = 0
			}

			m.setPRDetailsContent(prDetails, PRDetailsSectionList[section])
			m.prDetailsCurrentSection = section

		case "J", "]":
			if m.activePane != prDetailsView {
				break
			}

			m.prsList.CursorDown()
			prRes, ok := m.prsList.SelectedItem().(*prResult)
			if !ok {
				break
			}

			prDetails, ok := m.prDetailsCache[prRes.identifier]
			if !ok {
				break
			}

			var section uint
			lastSection, ok := m.prDetailsCurSectionCache[prRes.identifier]
			if ok {
				section = lastSection
			} else {
				section = 0
			}

			m.setPRDetailsContent(prDetails, PRDetailsSectionList[section])
			m.prDetailsCurrentSection = section

		case "d":
			if m.activePane != prListView && m.activePane != prDetailsView && m.activePane != prTLListView && m.activePane != prTLItemDetailView {
				break
			}

			if m.activePane == prDetailsView {
				m.activePane = m.lastPane
				break
			}

			prRes, ok := m.prsList.SelectedItem().(*prResult)
			if !ok {
				break
			}

			prDetails, ok := m.prDetailsCache[prRes.identifier]
			if !ok {
				m.message = "PR details were not retrieved"
				break
			}

			var section uint
			lastSection, ok := m.prDetailsCurSectionCache[prRes.identifier]
			if ok {
				section = lastSection
			} else {
				section = 0
			}

			m.setPRDetailsContent(prDetails, PRDetailsSectionList[section])
			m.prDetailsCurrentSection = section

			m.prDetailsVP.GotoTop()
			m.lastPane = m.activePane
			m.activePane = prDetailsView

		case "l", "n", "right":
			if m.activePane != prDetailsView && m.activePane != prTLItemDetailView {
				break
			}

			switch m.activePane {
			case prDetailsView:
				prRes, ok := m.prsList.SelectedItem().(*prResult)
				if !ok {
					break
				}

				prDetails, ok := m.prDetailsCache[prRes.identifier]
				if !ok {
					break
				}

				nextSectionFound := false
				var nextSection uint
				if m.prDetailsCurrentSection == uint(len(PRDetailsSectionList)-1) {
					nextSection = 0
				} else {
					nextSection = m.prDetailsCurrentSection + 1
				}

				for {
					switch nextSection {
					case 0:
						nextSectionFound = true
					case 1:
						if prDetails.Body != "" {
							nextSectionFound = true
						}
					case 2:
						// this may still lead to no status checks being shown
						// but the probability of that happening is pretty low
						if len(prDetails.LastCommit.Nodes) > 0 && prDetails.LastCommit.Nodes[0].Commit.StatusCheckRollup != nil {
							nextSectionFound = true
						}
					case 3:
						if len(prDetails.IssueReferences.Nodes) > 0 {
							nextSectionFound = true
						}
					case 4:
						if len(prDetails.Files.Nodes) > 0 {
							nextSectionFound = true
						}
					case 5:
						if len(prDetails.Commits.Nodes) > 0 {
							nextSectionFound = true
						}
					case 6:
						if len(prDetails.Comments.Nodes) > 0 {
							nextSectionFound = true
						} else {
							nextSection = 0
							nextSectionFound = true
						}
					}

					if nextSectionFound {
						break
					}

					nextSection++
				}

				if !nextSectionFound {
					break
				}

				if nextSection > uint(len(PRDetailsSectionList)-1) {
					m.message = "Something went wrong"
					break
				}

				m.setPRDetailsContent(prDetails, PRDetailsSectionList[nextSection])
				m.prDetailsCurSectionCache[prRes.identifier] = nextSection
				m.prDetailsCurrentSection = nextSection

			case prTLItemDetailView:
				tlItem, ok := m.prTLList.SelectedItem().(*prTLItemResult)
				if !ok {
					break
				}

				if tlItem.item.Type != tlItemPRReview {
					break
				}

				if len(tlItem.item.PullRequestReview.Comments.Nodes) <= 1 {
					break
				}

				nextCommentIndex := m.prRevCurCmtNum + 1
				if nextCommentIndex > uint(len(tlItem.item.PullRequestReview.Comments.Nodes))-1 {
					nextCommentIndex = 0
				}

				m.setPRReviewCmt(tlItem.item, nextCommentIndex)
				m.prRevCurCmtNum = nextCommentIndex
			}

		case "h", "N", "left":
			if m.activePane != prDetailsView && m.activePane != prTLItemDetailView {
				break
			}

			switch m.activePane {
			case prDetailsView:
				prRes, ok := m.prsList.SelectedItem().(*prResult)
				if !ok {
					break
				}

				prDetails, ok := m.prDetailsCache[prRes.identifier]
				if !ok {
					break
				}

				prevSectionFound := false
				var prevSection uint
				if m.prDetailsCurrentSection == 0 {
					prevSection = uint(len(PRDetailsSectionList) - 1)
				} else {
					prevSection = m.prDetailsCurrentSection - 1
				}

				for {
					switch prevSection {
					case 0:
						prevSectionFound = true
					case 1:
						if prDetails.Body != "" {
							prevSectionFound = true
						}
					case 2:
						if len(prDetails.LastCommit.Nodes) > 0 && prDetails.LastCommit.Nodes[0].Commit.StatusCheckRollup != nil {
							prevSectionFound = true
						}
					case 3:
						if len(prDetails.IssueReferences.Nodes) > 0 {
							prevSectionFound = true
						}
					case 4:
						if len(prDetails.Files.Nodes) > 0 {
							prevSectionFound = true
						}
					case 5:
						if len(prDetails.Commits.Nodes) > 0 {
							prevSectionFound = true
						}
					case 6:
						if len(prDetails.Comments.Nodes) > 0 {
							prevSectionFound = true
						}
					}

					if prevSectionFound {
						break
					}

					prevSection--
				}

				m.setPRDetailsContent(prDetails, PRDetailsSectionList[prevSection])
				m.prDetailsCurSectionCache[prRes.identifier] = prevSection
				m.prDetailsCurrentSection = prevSection

			case prTLItemDetailView:
				tlItem, ok := m.prTLList.SelectedItem().(*prTLItemResult)
				if !ok {
					break
				}

				if tlItem.item.Type != tlItemPRReview {
					break
				}

				if len(tlItem.item.PullRequestReview.Comments.Nodes) <= 1 {
					break
				}

				var prevCommentIndex uint
				if m.prRevCurCmtNum == 0 {
					prevCommentIndex = uint(len(tlItem.item.PullRequestReview.Comments.Nodes) - 1)
				} else {
					prevCommentIndex = m.prRevCurCmtNum - 1
				}

				m.setPRReviewCmt(tlItem.item, prevCommentIndex)
				m.prRevCurCmtNum = prevCommentIndex
			}

		case "?":
			if m.activePane == helpView {
				m.activePane = m.lastPane
				break
			}
			if m.activePane == prDetailsView {
				m.secondLastActivePane = m.lastPane
			}

			m.lastPane = m.activePane
			m.activePane = helpView
		}
	case hideHelpMsg:
		m.showHelp = false
	case tea.WindowSizeMsg:
		w, h := listStyle.GetFrameSize()
		m.terminalDetails.width = msg.Width

		if m.mode == RepoMode {
			m.repoList.SetHeight(msg.Height - h - 2)
			m.repoList.SetWidth(msg.Width - w)
		}

		m.prsList.SetHeight(msg.Height - h - 2)
		m.prsList.SetWidth(msg.Width - w)

		m.prTLList.SetHeight(msg.Height - h - 2)
		m.prTLList.SetWidth(msg.Width - w)

		if !m.prTLItemDetailVPReady {
			m.prTLItemDetailVP = viewport.New(
				viewport.WithWidth(msg.Width-2),
				viewport.WithHeight(msg.Height-7),
			)
			m.prTLItemDetailVPReady = true
			m.prTLItemDetailVP.KeyMap.HalfPageDown.SetKeys("ctrl+d")
		} else {
			m.prTLItemDetailVP.SetWidth(msg.Width - 2)
			m.prTLItemDetailVP.SetHeight(msg.Height - 7)
		}

		if !m.prDetailsVPReady {
			m.prDetailsVP = viewport.New(
				viewport.WithWidth(msg.Width-2),
				viewport.WithHeight(msg.Height-7),
			)
			m.prDetailsVPReady = true
			m.prDetailsVP.KeyMap.HalfPageDown.SetKeys("ctrl+d")
		} else {
			m.prDetailsVP.SetWidth(msg.Width - 2)
			m.prDetailsVP.SetHeight(msg.Height - 7)
		}

		vpWrap := min((msg.Width - 4), viewPortWrapUpperLimit)

		m.mdRenderer, _ = utils.GetMarkDownRenderer(vpWrap)

		helpToRender := helpStr
		if m.mdRenderer != nil {
			helpStrGl, err := m.mdRenderer.Render(helpStr)
			if err != nil {
				break
			}
			helpToRender = helpStrGl
		}

		if !m.helpVPReady {
			m.helpVP = viewport.New(
				viewport.WithWidth(msg.Width),
				viewport.WithHeight(msg.Height-7),
			)
			m.helpVP.SetContent(helpToRender)
			m.helpVPReady = true
		} else {
			m.helpVP.SetWidth(msg.Width)
			m.helpVP.SetHeight(msg.Height - 7)
		}

		prs := make([]list.Item, len(m.prCache))
		for i := range m.prCache {
			m.prCache[i].title = getPRTitle(m.prCache[i].pr)
			m.prCache[i].description = getPRDesc(m.prCache[i].pr, m.mode, m.terminalDetails)
			prs[i] = m.prCache[i]
		}
		m.prsList.SetItems(prs)

		if m.activePane == prTLListView {
			m.setTL()
		}

	case repoChosenMsg:
		repoDetails := strings.Split(msg.repo, ":::")
		if len(repoDetails) != 2 {
			m.message = "Something went horribly wrong. Let @dhth know about this failure."
		} else {
			m.repoChosen = true
			m.prsList.Title = fetchingPRsTitle
			m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(fetchingColor))
			m.repoOwner = repoDetails[0]
			m.repoName = repoDetails[1]
			m.activePane = prListView
			m.prsList.ResetSelected()
			m.prTLList.ResetSelected()
			cmds = append(cmds, fetchPRSForRepo(m.ghClient, m.repoOwner, m.repoName, m.config.PRCount))
		}
	case prsFetchedMsg:
		if msg.err != nil {
			m.message = msg.err.Error()
			m.prsList.Title = "error"
			break
		}

		prs := make([]list.Item, len(msg.prs))
		prResults := make([]*prResult, len(msg.prs))
		m.prDetailsCurSectionCache = make(map[string]uint)

		for i, pr := range msg.prs {
			prResults[i] = &prResult{
				pr:          &pr,
				title:       getPRTitle(&pr),
				description: getPRDesc(&pr, m.mode, m.terminalDetails),
				identifier:  fmt.Sprintf("%s/%s:%d", pr.Repository.Owner.Login, pr.Repository.Name, pr.Number),
			}
			prs[i] = prResults[i]
		}

		m.prCache = prResults
		m.prsList.SetItems(prs)

		switch m.mode {
		case RepoMode:
			m.prsList.Title = fmt.Sprintf("PRs (%s)", m.repoName)
		case QueryMode:
			m.prsList.Title = "Results"
		}

		m.prsList.ResetSelected()
		m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(prListColor))

		for _, pr := range msg.prs {
			cmds = append(cmds, fetchPRTLItems(m.ghClient,
				pr.Repository.Owner.Login,
				pr.Repository.Name,
				pr.Number,
				100,
				false,
			))
			cmds = append(cmds, fetchPRMetadata(m.ghClient,
				pr.Repository.Owner.Login,
				pr.Repository.Name,
				pr.Number,
			))
		}

	case reviewPRsFetchedMsg:
		if msg.err != nil {
			m.message = msg.err.Error()
			break
		}

		prs := make([]list.Item, len(msg.prs))
		prResults := make([]*prResult, len(msg.prs))
		m.prDetailsCurSectionCache = make(map[string]uint)

		for i, pr := range msg.prs {
			prResults[i] = &prResult{
				pr:          &pr,
				title:       getPRTitle(&pr),
				description: getPRDesc(&pr, m.mode, m.terminalDetails),
				identifier:  fmt.Sprintf("%s/%s:%d", pr.Repository.Owner.Login, pr.Repository.Name, pr.Number),
			}
			prs[i] = prResults[i]
		}

		m.prCache = prResults
		m.prsList.SetItems(prs)
		m.prsList.ResetSelected()
		m.prsList.Title = "Open PRs requesting your review"
		m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(prListColor))

		if len(msg.prs) > 0 {
			for _, pr := range msg.prs {
				cmds = append(cmds, fetchPRTLItems(m.ghClient, pr.Repository.Owner.Login, pr.Repository.Name, pr.Number, 100, false))
				cmds = append(cmds, fetchPRMetadata(m.ghClient,
					pr.Repository.Owner.Login,
					pr.Repository.Name,
					pr.Number,
				))
			}
		}
	case authoredPRsFetchedMsg:
		if msg.err != nil {
			m.message = msg.err.Error()
			break
		}

		prs := make([]list.Item, len(msg.prs))
		prResults := make([]*prResult, len(msg.prs))
		m.prDetailsCurSectionCache = make(map[string]uint)

		for i, pr := range msg.prs {
			prResults[i] = &prResult{
				pr:          &pr,
				title:       getPRTitle(&pr),
				description: getPRDesc(&pr, m.mode, m.terminalDetails),
				identifier:  fmt.Sprintf("%s/%s:%d", pr.Repository.Owner.Login, pr.Repository.Name, pr.Number),
			}
			prs[i] = prResults[i]
		}

		m.prCache = prResults
		m.prsList.SetItems(prs)
		m.prsList.Title = "Open PRs authored by you"
		m.prsList.ResetSelected()
		m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(prListColor))

		if len(msg.prs) > 0 {
			for _, pr := range msg.prs {
				cmds = append(cmds, fetchPRTLItems(m.ghClient, pr.Repository.Owner.Login, pr.Repository.Name, pr.Number, 100, false))
				cmds = append(cmds, fetchPRMetadata(m.ghClient,
					pr.Repository.Owner.Login,
					pr.Repository.Name,
					pr.Number,
				))
			}
		}

	case prMetadataFetchedMsg:
		if msg.err != nil {
			m.message = msg.err.Error()
			break
		}

		m.prDetailsCache[fmt.Sprintf("%s/%s:%d", msg.repoOwner, msg.repoName, msg.prNumber)] = msg.metadata

	case prTLFetchedMsg:
		if msg.err != nil {
			m.message = msg.err.Error()
			break
		}

		tlItemsResult := make([]*prTLItemResult, len(msg.prTLItems))

		for i, item := range msg.prTLItems {
			tlItemsResult[i] = &prTLItemResult{
				item:        &item,
				title:       getPRTLItemTitle(&item),
				description: getPRTLItemDesc(&item),
			}
		}
		m.prTLCache[fmt.Sprintf("%s/%s:%d", msg.repoOwner, msg.repoName, msg.prNumber)] = tlItemsResult

		if msg.setItems {
			prTLItems := make([]list.Item, len(msg.prTLItems))
			for i, result := range tlItemsResult {
				prTLItems[i] = result
			}
			m.prTLList.SetItems(prTLItems)
			m.prTLList.Title = fmt.Sprintf("PR #%d Timeline", msg.prNumber)
			m.prTLList.Styles.Title = m.prTLList.Styles.Title.Background(lipgloss.Color(prTLListColor))
			m.activePane = prTLListView
		}

		m.prTLList.ResetSelected()

	case urlOpenedinBrowserMsg:
		if msg.err != nil {
			m.message = fmt.Sprintf("Error opening url: %s", msg.err.Error())
		}
	case prDiffDoneMsg:
		if msg.err != nil {
			m.message = fmt.Sprintf("Error opening diff (is gh installed?): %s", msg.err.Error())
		}
	case prViewDoneMsg:
		if msg.err != nil {
			m.message = fmt.Sprintf("Error showing PR details (is gh installed?): %s", msg.err.Error())
		}
	}

	switch m.activePane {
	case prListView:
		m.prsList, cmd = m.prsList.Update(msg)
		cmds = append(cmds, cmd)
	case prTLListView:
		m.prTLList, cmd = m.prTLList.Update(msg)
		cmds = append(cmds, cmd)
	case prDetailsView:
		m.prDetailsVP, cmd = m.prDetailsVP.Update(msg)
		cmds = append(cmds, cmd)
	case prTLItemDetailView:
		m.prTLItemDetailVP, cmd = m.prTLItemDetailVP.Update(msg)
		cmds = append(cmds, cmd)
	case repoListView:
		m.repoList, cmd = m.repoList.Update(msg)
		cmds = append(cmds, cmd)
	case helpView:
		m.helpVP, cmd = m.helpVP.Update(msg)
		cmds = append(cmds, cmd)
	}

	return m, tea.Batch(cmds...)
}

func (m *Model) setTL() (tea.Cmd, bool) {
	var cmd tea.Cmd
	var repoOwner, repoName string
	var prNumber int

	prRes, prOk := m.prsList.SelectedItem().(*prResult)
	if !prOk {
		return nil, false
	}

	repoOwner = prRes.pr.Repository.Owner.Login
	repoName = prRes.pr.Repository.Name
	prNumber = prRes.pr.Number

	tlFromCache, ok := m.prTLCache[prRes.identifier]
	if !ok {
		cmd = fetchPRTLItems(m.ghClient, repoOwner, repoName, prNumber, 100, true)
		return cmd, true
	}

	tlItems := make([]list.Item, len(tlFromCache))

	// this list always get rerendered as it seems to be preferrable over recomputing the string rep of every item in
	// every list in m.prTLCache when the terminal window is resized
	for i, result := range tlFromCache {
		title := getPRTLItemTitle(result.item)
		description := getPRTLItemDesc(result.item)

		result.title = title
		result.description = description

		tlItems[i] = result
	}

	m.prTLList.SetItems(tlItems)
	m.prTLList.Title = fmt.Sprintf("PR #%d Timeline", prNumber)
	m.activePane = prTLListView

	return nil, true
}


================================================
FILE: ui/utils.go
================================================
package ui

import (
	"strings"
)

func RightPadTrim(s string, length int) string {
	if len(s) >= length {
		if length > 3 {
			return s[:length-3] + "..."
		}
		return s[:length]
	}
	return s + strings.Repeat(" ", length-len(s))
}

func Trim(s string, length int) string {
	if len(s) >= length {
		if length > 3 {
			return s[:length-3] + "..."
		}
		return s[:length]
	}
	return s
}

func getFracInt(num int, frac float32) int {
	return int(float32(num) * frac)
}


================================================
FILE: ui/view.go
================================================
package ui

import (
	"fmt"

	tea "charm.land/bubbletea/v2"
	"charm.land/lipgloss/v2"
)

const (
	viewPortWrapUpperLimit = 160
	vpNotReadyMsg          = "Initializing..."
)

func (m Model) View() tea.View {
	var content string
	var footer string

	var statusBar string
	if m.message != "" {
		statusBar = RightPadTrim(m.message, m.terminalDetails.width)
	}

	switch m.activePane {
	case prListView:
		content = listStyle.Render(m.prsList.View())
	case prTLListView:
		content = listStyle.Render(m.prTLList.View())
	case repoListView:
		content = listStyle.Render(m.repoList.View())
	case prDetailsView:
		if !m.prTLItemDetailVPReady {
			content = vpNotReadyMsg
		} else {
			content = viewPortStyle.Render(fmt.Sprintf("  %s\n\n%s\n",
				prDetailsTitleStyle.Render(m.prDetailsTitle),
				m.prDetailsVP.View()))
		}
	case prTLItemDetailView:
		var prRevCmtsVP string
		if !m.prTLItemDetailVPReady {
			prRevCmtsVP = vpNotReadyMsg
		} else {
			prRevCmtsVP = viewPortStyle.Render(fmt.Sprintf("  %s\n\n%s\n",
				helpVPTitleStyle.Render(m.prTLItemDetailTitle),
				m.prTLItemDetailVP.View()))
		}
		content = prRevCmtsVP
	case helpView:
		var helpVP string
		if !m.helpVPReady {
			helpVP = vpNotReadyMsg
		} else {
			helpVP = viewPortStyle.Render(fmt.Sprintf("  %s\n\n%s\n",
				helpVPTitleStyle.Render("Help"),
				m.helpVP.View()))
		}
		content = helpVP
	}

	var helpMsg string
	if m.showHelp {
		helpMsg = helpMsgStyle.Render("Press ? for help")
	}

	footerStr := fmt.Sprintf("%s%s",
		toolNameStyle.Render("prs"),
		helpMsg,
	)
	footer = footerStyle.Render(footerStr)

	rendered := lipgloss.JoinVertical(lipgloss.Left,
		content,
		statusBar,
		footer,
	)

	v := tea.NewView(rendered)
	v.AltScreen = true

	return v
}


================================================
FILE: yamlfmt.yml
================================================
formatter:
  retain_line_breaks_single: true
Download .txt
gitextract_hwcw2gwt/

├── .gitattributes
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   ├── config.yml
│   │   └── feature_request.md
│   ├── dependabot.yml
│   ├── scripts/
│   │   └── get-yamlfmt.sh
│   └── workflows/
│       ├── main.yml
│       ├── pr.yml
│       ├── release.yml
│       ├── scan.yml
│       └── vulncheck.yml
├── .gitignore
├── .golangci.yml
├── .goreleaser.yaml
├── AGENTS.md
├── LICENSE
├── README.md
├── cmd/
│   ├── config.go
│   └── root.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── internal/
│   └── utils/
│       ├── assets/
│       │   └── gruvbox.json
│       ├── markdown.go
│       └── markdown_test.go
├── main.go
├── ui/
│   ├── assets/
│   │   └── help.md
│   ├── cmds.go
│   ├── colors.go
│   ├── gh.go
│   ├── initial.go
│   ├── model.go
│   ├── msgs.go
│   ├── navigation.go
│   ├── pr_delegate.go
│   ├── prtl_delegate.go
│   ├── render_helpers.go
│   ├── repo_delegate.go
│   ├── styles.go
│   ├── types.go
│   ├── ui.go
│   ├── update.go
│   ├── utils.go
│   └── view.go
└── yamlfmt.yml
Download .txt
SYMBOL INDEX (187 symbols across 21 files)

FILE: cmd/config.go
  function expandTilde (line 9) | func expandTilde(path string) string {

FILE: cmd/root.go
  constant envPrefix (line 21) | envPrefix          = "PRS"
  constant author (line 22) | author             = "@dhth"
  constant projectHomePage (line 23) | projectHomePage    = "https://github.com/dhth/prs"
  constant issuesURL (line 24) | issuesURL          = "https://github.com/dhth/prs/issues"
  constant configFileName (line 25) | configFileName     = "prs/prs.yml"
  constant defaultSearchQuery (line 26) | defaultSearchQuery = "type:pr author:@me sort:updated-desc state:open"
  constant defaultPRNum (line 27) | defaultPRNum       = 20
  constant maxPRNum (line 28) | maxPRNum           = 50
  function Execute (line 43) | func Execute(version string) error {
  function NewRootCommand (line 69) | func NewRootCommand(version string) (*cobra.Command, error) {
  function initializeConfig (line 224) | func initializeConfig(cmd *cobra.Command, configFile string) (*viper.Vip...
  function bindFlags (line 248) | func bindFlags(cmd *cobra.Command, v *viper.Viper) error {

FILE: internal/utils/markdown.go
  function GetMarkDownRenderer (line 13) | func GetMarkDownRenderer(wrap int) (*glamour.TermRenderer, error) {

FILE: internal/utils/markdown_test.go
  function TestGetGlamourStyleFromFile (line 12) | func TestGetGlamourStyleFromFile(t *testing.T) {
  function TestGlamourStylesFileIsValid (line 22) | func TestGlamourStylesFileIsValid(t *testing.T) {

FILE: main.go
  function main (line 12) | func main() {

FILE: ui/cmds.go
  function chooseRepo (line 16) | func chooseRepo(repo string) tea.Cmd {
  function openURLInBrowser (line 22) | func openURLInBrowser(url string) tea.Cmd {
  function showDiff (line 48) | func showDiff(repoOwner, repoName string, prNumber int) tea.Cmd {
  function showPR (line 67) | func showPR(repoOwner, repoName string, prNumber int) tea.Cmd {
  function hideHelp (line 87) | func hideHelp(interval time.Duration) tea.Cmd {
  function fetchPRSFromQuery (line 93) | func fetchPRSFromQuery(ghClient *ghapi.GraphQLClient, queryStr string, p...
  function fetchPRSForRepo (line 100) | func fetchPRSForRepo(ghClient *ghapi.GraphQLClient, repoOwner string, re...
  function fetchPRMetadata (line 108) | func fetchPRMetadata(ghClient *ghapi.GraphQLClient, repoOwner, repoName ...
  function fetchPRTLItems (line 115) | func fetchPRTLItems(ghClient *ghapi.GraphQLClient, repoOwner string, rep...

FILE: ui/gh.go
  function getPRDataFromQuery (line 10) | func getPRDataFromQuery(ghClient *ghapi.GraphQLClient, queryStr string, ...
  function getPRMetadata (line 31) | func getPRMetadata(ghClient *ghapi.GraphQLClient, repoOwner string, repo...
  function getPRTLData (line 57) | func getPRTLData(ghClient *ghapi.GraphQLClient, repoOwner string, repoNa...

FILE: ui/initial.go
  constant fetchingPRsTitle (line 10) | fetchingPRsTitle = "fetching PRs..."
  function InitialModel (line 13) | func InitialModel(ghClient *ghapi.GraphQLClient, config Config, mode Mod...

FILE: ui/model.go
  type Pane (line 13) | type Pane
  constant repoListView (line 16) | repoListView Pane = iota
  constant prListView (line 17) | prListView
  constant prDetailsView (line 18) | prDetailsView
  constant reviewPRListView (line 19) | reviewPRListView
  constant prTLListView (line 20) | prTLListView
  constant prTLItemDetailView (line 21) | prTLItemDetailView
  constant helpView (line 22) | helpView
  type Mode (line 25) | type Mode
  constant QueryMode (line 28) | QueryMode Mode = iota
  constant RepoMode (line 29) | RepoMode
  type Model (line 32) | type Model struct
    method Init (line 65) | func (m Model) Init() tea.Cmd {

FILE: ui/msgs.go
  type hideHelpMsg (line 3) | type hideHelpMsg struct
  type repoChosenMsg (line 5) | type repoChosenMsg struct
  type prsFetchedMsg (line 9) | type prsFetchedMsg struct
  type prMetadataFetchedMsg (line 14) | type prMetadataFetchedMsg struct
  type reviewPRsFetchedMsg (line 22) | type reviewPRsFetchedMsg
  type authoredPRsFetchedMsg (line 24) | type authoredPRsFetchedMsg
  type prTLFetchedMsg (line 26) | type prTLFetchedMsg struct
  type urlOpenedinBrowserMsg (line 35) | type urlOpenedinBrowserMsg struct
  type prDiffDoneMsg (line 40) | type prDiffDoneMsg struct
  type prViewDoneMsg (line 44) | type prViewDoneMsg struct

FILE: ui/navigation.go
  constant maxCommentsForNavIndicator (line 9) | maxCommentsForNavIndicator = 8
  constant disabledSectionMarker (line 10) | disabledSectionMarker      = "◌"
  constant inactiveSectionMarker (line 11) | inactiveSectionMarker      = "◯"
  constant activeSectionMarker (line 12) | activeSectionMarker        = "●"
  method setPRDetailsContent (line 15) | func (m *Model) setPRDetailsContent(prDetails prDetails, section PRDetai...
  method GoToPRDetailSection (line 85) | func (m *Model) GoToPRDetailSection(section uint) {
  method setPRReviewCmt (line 130) | func (m *Model) setPRReviewCmt(tlItem *prTLItem, commentNum uint) {

FILE: ui/pr_delegate.go
  function newPRListItemDel (line 8) | func newPRListItemDel() list.DefaultDelegate {

FILE: ui/prtl_delegate.go
  function newPRTLListItemDel (line 8) | func newPRTLListItemDel() list.DefaultDelegate {

FILE: ui/render_helpers.go
  constant widthBudgetDefault (line 11) | widthBudgetDefault    = 80
  constant responsiveWidthCutOff (line 12) | responsiveWidthCutOff = 100
  constant wideScreenWidthFrac (line 13) | wideScreenWidthFrac   = 0.9
  function getPRTitle (line 16) | func getPRTitle(pr *pr) string {
  function getPRDesc (line 36) | func getPRDesc(pr *pr, mode Mode, terminalDetails terminalDetails) string {
  function getPRTLItemTitle (line 97) | func getPRTLItemTitle(item *prTLItem) string {
  function getPRTLItemDesc (line 150) | func getPRTLItemDesc(item *prTLItem) string {

FILE: ui/repo_delegate.go
  function newRepoListItemDel (line 8) | func newRepoListItemDel() list.DefaultDelegate {

FILE: ui/styles.go
  constant defaultBackgroundColor (line 10) | defaultBackgroundColor      = "#282828"
  constant repoListColor (line 11) | repoListColor               = "#b8bb26"
  constant prListColor (line 12) | prListColor                 = "#fe8019"
  constant prTLListColor (line 13) | prTLListColor               = "#d3869b"
  constant prDetailsTitleColor (line 14) | prDetailsTitleColor         = "#fabd2f"
  constant revCmtListColor (line 15) | revCmtListColor             = "#8ec07c"
  constant prOpenColor (line 16) | prOpenColor                 = "#fabd2f"
  constant prMergedColor (line 17) | prMergedColor               = "#b8bb26"
  constant prClosedColor (line 18) | prClosedColor               = "#928374"
  constant additionsColor (line 19) | additionsColor              = "#8ec07c"
  constant deletionsColor (line 20) | deletionsColor              = "#fb4934"
  constant reviewCommentedColor (line 21) | reviewCommentedColor        = "#83a598"
  constant reviewApprovedColor (line 22) | reviewApprovedColor         = "#b8bb26"
  constant reviewChangesRequestedColor (line 23) | reviewChangesRequestedColor = "#fabd2f"
  constant reviewDismissedColor (line 24) | reviewDismissedColor        = "#928374"
  constant dateColor (line 25) | dateColor                   = "#928374"
  constant repoColor (line 26) | repoColor                   = "#bdae93"
  constant numReviewsColor (line 27) | numReviewsColor             = "#665c54"
  constant numCommentsColor (line 28) | numCommentsColor            = "#83a598"
  constant footerColor (line 29) | footerColor                 = "#7c6f64"
  constant helpMsgColor (line 30) | helpMsgColor                = "#83a598"
  constant helpViewTitleColor (line 31) | helpViewTitleColor          = "#83a598"
  constant toolNameColor (line 32) | toolNameColor               = "#b8bb26"
  constant fetchingColor (line 33) | fetchingColor               = "#928374"
  function getDynamicStyle (line 36) | func getDynamicStyle(author string) lipgloss.Style {

FILE: ui/types.go
  constant prStateOpen (line 12) | prStateOpen                 = "OPEN"
  constant prStateMerged (line 13) | prStateMerged               = "MERGED"
  constant prStateClosed (line 14) | prStateClosed               = "CLOSED"
  constant prRevDecChangesReq (line 15) | prRevDecChangesReq          = "CHANGES_REQUESTED"
  constant prRevDecApproved (line 16) | prRevDecApproved            = "APPROVED"
  constant prRevDecRevReq (line 17) | prRevDecRevReq              = "REVIEW_REQUIRED"
  constant tlItemPRCommit (line 18) | tlItemPRCommit              = "PullRequestCommit"
  constant tlItemPRReadyForReview (line 19) | tlItemPRReadyForReview      = "ReadyForReviewEvent"
  constant tlItemPRReviewRequested (line 20) | tlItemPRReviewRequested     = "ReviewRequestedEvent"
  constant tlItemPRReview (line 21) | tlItemPRReview              = "PullRequestReview"
  constant tlItemMergedEvent (line 22) | tlItemMergedEvent           = "MergedEvent"
  constant tlItemHeadRefForcePushed (line 23) | tlItemHeadRefForcePushed    = "HeadRefForcePushedEvent"
  constant reviewPending (line 24) | reviewPending               = "PENDING"
  constant reviewCommented (line 25) | reviewCommented             = "COMMENTED"
  constant reviewApproved (line 26) | reviewApproved              = "APPROVED"
  constant reviewChangesRequested (line 27) | reviewChangesRequested      = "CHANGES_REQUESTED"
  constant reviewDismissed (line 28) | reviewDismissed             = "DISMISSED"
  constant checkStatusStateCompleted (line 29) | checkStatusStateCompleted   = "COMPLETED"
  constant checkRunType (line 30) | checkRunType                = "CheckRun"
  constant statusContextType (line 31) | statusContextType           = "StatusContext"
  constant checkConclusionStateSuccess (line 32) | checkConclusionStateSuccess = "SUCCESS"
  constant checkConclusionStateFailure (line 33) | checkConclusionStateFailure = "FAILURE"
  constant checkConclusionStateError (line 34) | checkConclusionStateError   = "ERROR"
  constant statusStateSuccess (line 35) | statusStateSuccess          = "SUCCESS"
  constant statusStateFailure (line 36) | statusStateFailure          = "FAILURE"
  constant statusStateError (line 37) | statusStateError            = "ERROR"
  constant requestedReviewerUser (line 38) | requestedReviewerUser       = "User"
  constant prDetailsMetadataKeyPadding (line 39) | prDetailsMetadataKeyPadding = 30
  constant checkNamePadding (line 40) | checkNamePadding            = 40
  constant statusConclusionPadding (line 41) | statusConclusionPadding     = 16
  constant reviewRequestsCount (line 42) | reviewRequestsCount         = 20
  constant latestReviewsCount (line 43) | latestReviewsCount          = 30
  constant filesCount (line 44) | filesCount                  = 50
  constant labelsCount (line 45) | labelsCount                 = 10
  constant assigneesCount (line 46) | assigneesCount              = 10
  constant issuesCount (line 47) | issuesCount                 = 10
  constant participantsCount (line 48) | participantsCount           = 30
  constant commentsCount (line 49) | commentsCount               = 10
  constant commitsCount (line 50) | commitsCount                = 30
  constant statusCheckContextsCount (line 51) | statusCheckContextsCount    = 50
  constant timeFormat (line 52) | timeFormat                  = "2006/01/02 15:04"
  constant mergeableConflicting (line 53) | mergeableConflicting        = "CONFLICTING"
  constant noChecksHeader (line 54) | noChecksHeader              = "## No Checks"
  type terminalDetails (line 57) | type terminalDetails struct
  type SourceConfig (line 61) | type SourceConfig struct
  type Repo (line 73) | type Repo struct
    method Title (line 697) | func (repo Repo) Title() string {
    method Description (line 701) | func (repo Repo) Description() string {
    method FilterValue (line 705) | func (repo Repo) FilterValue() string {
  type Config (line 78) | type Config struct
  type prResult (line 84) | type prResult struct
    method Title (line 709) | func (prRes prResult) Title() string {
    method Description (line 713) | func (prRes prResult) Description() string {
    method FilterValue (line 717) | func (prRes prResult) FilterValue() string {
  type prTLItemResult (line 91) | type prTLItemResult struct
    method Title (line 721) | func (ir prTLItemResult) Title() string {
    method Description (line 725) | func (ir prTLItemResult) Description() string {
    method FilterValue (line 729) | func (ir prTLItemResult) FilterValue() string {
  type pr (line 97) | type pr struct
  type prDetails (line 126) | type prDetails struct
    method Metadata (line 385) | func (pr prDetails) Metadata() string {
    method Description (line 547) | func (pr prDetails) Description() string {
    method Checks (line 554) | func (pr prDetails) Checks() string {
    method References (line 617) | func (pr prDetails) References() string {
    method FilesChanged (line 628) | func (pr prDetails) FilesChanged() string {
    method CommitsList (line 650) | func (pr prDetails) CommitsList() string {
    method CommentsList (line 679) | func (pr prDetails) CommentsList() string {
  type PRDetailSection (line 252) | type PRDetailSection
  constant PRMetadata (line 255) | PRMetadata PRDetailSection = iota
  constant PRDescription (line 256) | PRDescription
  constant PRChecks (line 257) | PRChecks
  constant PRReferences (line 258) | PRReferences
  constant PRFilesChanged (line 259) | PRFilesChanged
  constant PRCommits (line 260) | PRCommits
  constant PRComments (line 261) | PRComments
  type prReviewComment (line 274) | type prReviewComment struct
  type prSearchQuery (line 283) | type prSearchQuery struct
  type prDetailsQuery (line 294) | type prDetailsQuery struct
  type prTLItem (line 302) | type prTLItem struct
  type prTLQuery (line 373) | type prTLQuery struct

FILE: ui/ui.go
  function RenderUI (line 11) | func RenderUI(ghClient *ghapi.GraphQLClient, config Config, mode Mode) e...

FILE: ui/update.go
  constant viewPortMoveLineCount (line 17) | viewPortMoveLineCount  = 5
  constant couldntGetPRDetailsMsg (line 18) | couldntGetPRDetailsMsg = "Couldn't get repo/pr details. Inform @dhth on ...
  method Update (line 28) | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  method setTL (line 945) | func (m *Model) setTL() (tea.Cmd, bool) {

FILE: ui/utils.go
  function RightPadTrim (line 7) | func RightPadTrim(s string, length int) string {
  function Trim (line 17) | func Trim(s string, length int) string {
  function getFracInt (line 27) | func getFracInt(num int, frac float32) int {

FILE: ui/view.go
  constant viewPortWrapUpperLimit (line 11) | viewPortWrapUpperLimit = 160
  constant vpNotReadyMsg (line 12) | vpNotReadyMsg          = "Initializing..."
  method View (line 15) | func (m Model) View() tea.View {
Condensed preview — 45 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (144K chars).
[
  {
    "path": ".gitattributes",
    "chars": 58,
    "preview": "* linguist-detectable=false\n*.go linguist-detectable=true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 815,
    "preview": "---\nname: Bug report\nabout: Create a report to help improve prs\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the "
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "chars": 28,
    "preview": "blank_issues_enabled: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "chars": 595,
    "preview": "---\nname: Feature request\nabout: Suggest an idea for prs\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n**Describe th"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 478,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\"\n    groups"
  },
  {
    "path": ".github/scripts/get-yamlfmt.sh",
    "chars": 697,
    "preview": "#!/usr/bin/env bash\n\nset -e\n\nif [ $# -ne 3 ]; then\n    echo \"Usage: $0 <os> <arch> <version>\"\n    echo \"eg: $0 Linux x86"
  },
  {
    "path": ".github/workflows/main.yml",
    "chars": 3437,
    "preview": "name: main\n\non:\n  push:\n    branches:\n      - 'main'\n\njobs:\n  changes:\n    runs-on: ubuntu-latest\n    permissions:\n     "
  },
  {
    "path": ".github/workflows/pr.yml",
    "chars": 3413,
    "preview": "name: pr\n\non:\n  pull_request:\n\njobs:\n  changes:\n    runs-on: ubuntu-latest\n    permissions:\n      pull-requests: read\n  "
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 648,
    "preview": "name: release\n\non:\n  push:\n    tags:\n      - 'v*'\n\npermissions:\n  id-token: write\n\njobs:\n  release:\n    runs-on: ubuntu-"
  },
  {
    "path": ".github/workflows/scan.yml",
    "chars": 670,
    "preview": "name: scan\n\non:\n  workflow_dispatch:\n  schedule:\n    - cron: '0 8 10 * *'\n\njobs:\n  virus-total:\n    runs-on: ubuntu-late"
  },
  {
    "path": ".github/workflows/vulncheck.yml",
    "chars": 436,
    "preview": "name: vulncheck\n\non:\n  workflow_dispatch:\n  schedule:\n    - cron: '0 2 * * 2,6'\n\njobs:\n  vulncheck:\n    runs-on: ubuntu-"
  },
  {
    "path": ".gitignore",
    "chars": 44,
    "preview": "prs\n.quickrun\ncosign.key\njustfile\ndebug.log\n"
  },
  {
    "path": ".golangci.yml",
    "chars": 1646,
    "preview": "version: \"2\"\nlinters:\n  enable:\n    - errname\n    - errorlint\n    - goconst\n    - nilerr\n    - prealloc\n    - predeclare"
  },
  {
    "path": ".goreleaser.yaml",
    "chars": 884,
    "preview": "version: 2\n\nrelease:\n  draft: true\n\nbefore:\n  hooks:\n    - go mod tidy\n    - go generate ./...\n\nbuilds:\n  - env:\n      -"
  },
  {
    "path": "AGENTS.md",
    "chars": 2153,
    "preview": "# AGENTS.md\n\nThis file provides guidance to AI coding agents when working with code in this repository.\n\n## Project Over"
  },
  {
    "path": "LICENSE",
    "chars": 1074,
    "preview": "MIT License\n\nCopyright (c) 2024-2026 Dhruv Thakur\n\nPermission is hereby granted, free of charge, to any person obtaining"
  },
  {
    "path": "README.md",
    "chars": 7615,
    "preview": "<p align=\"center\">\n  <h1 align=\"center\">prs</h1>\n  <p align=\"center\">\n    <a href=\"https://github.com/dhth/prs/actions/w"
  },
  {
    "path": "cmd/config.go",
    "chars": 262,
    "preview": "package cmd\n\nimport (\n\t\"os\"\n\t\"os/user\"\n\t\"strings\"\n)\n\nfunc expandTilde(path string) string {\n\tif strings.HasPrefix(path, "
  },
  {
    "path": "cmd/root.go",
    "chars": 7255,
    "preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\tghapi \"github.com"
  },
  {
    "path": "docker-compose.yml",
    "chars": 187,
    "preview": "version: '3.8'\n\n# to test workings on linux\nservices:\n  prs-dev:\n    image: golang:1.23-alpine\n    volumes:\n      - .:/g"
  },
  {
    "path": "go.mod",
    "chars": 3147,
    "preview": "module github.com/dhth/prs\n\ngo 1.26.3\n\nrequire (\n\tcharm.land/bubbles/v2 v2.1.0\n\tcharm.land/bubbletea/v2 v2.0.6\n\tcharm.la"
  },
  {
    "path": "go.sum",
    "chars": 14789,
    "preview": "charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=\ncharm.land/bubbles/v2 v2.1.0/go.mod h1:l97h"
  },
  {
    "path": "internal/utils/assets/gruvbox.json",
    "chars": 3659,
    "preview": "{\n  \"document\": {\n    \"block_prefix\": \"\",\n    \"block_suffix\": \"\",\n    \"color\": \"#fbf1c7\",\n    \"margin\": 2\n  },\n  \"block_"
  },
  {
    "path": "internal/utils/markdown.go",
    "chars": 400,
    "preview": "package utils\n\nimport (\n\t_ \"embed\"\n\n\t\"github.com/charmbracelet/glamour\"\n\t\"github.com/muesli/termenv\"\n)\n\n//go:embed asset"
  },
  {
    "path": "internal/utils/markdown_test.go",
    "chars": 557,
    "preview": "package utils\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/charmbracelet/glamour\"\n\t\"github.com/stretchr/testify/a"
  },
  {
    "path": "main.go",
    "chars": 285,
    "preview": "package main\n\nimport (\n\t\"os\"\n\t\"runtime/debug\"\n\n\t\"github.com/dhth/prs/cmd\"\n)\n\nvar version = \"dev\"\n\nfunc main() {\n\tv := ve"
  },
  {
    "path": "ui/assets/help.md",
    "chars": 2339,
    "preview": "# prs Reference Manual\n\n(scroll line by line with j/k/arrow keys or by half a page with c-d/c-u)\n\n## Views\n\nprs has 6 vi"
  },
  {
    "path": "ui/cmds.go",
    "chars": 2947,
    "preview": "package ui\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"runtime\"\n\t\"time\"\n\n\ttea \"charm.land/bubbletea/v2\"\n\tghapi \"github.com/c"
  },
  {
    "path": "ui/colors.go",
    "chars": 1861,
    "preview": "package ui\n\nvar colors = []string{\n\t\"#fe77a4\",\n\t\"#d3869a\",\n\t\"#ff4c8b\",\n\t\"#ffb0c2\",\n\t\"#df748b\",\n\t\"#ff6682\",\n\t\"#f19597\",\n\t"
  },
  {
    "path": "ui/gh.go",
    "chars": 2278,
    "preview": "package ui\n\nimport (\n\t\"log\"\n\n\tghapi \"github.com/cli/go-gh/v2/pkg/api\"\n\tghgql \"github.com/cli/shurcooL-graphql\"\n)\n\nfunc g"
  },
  {
    "path": "ui/initial.go",
    "chars": 2722,
    "preview": "package ui\n\nimport (\n\t\"charm.land/bubbles/v2/list\"\n\t\"charm.land/lipgloss/v2\"\n\tghapi \"github.com/cli/go-gh/v2/pkg/api\"\n)\n"
  },
  {
    "path": "ui/model.go",
    "chars": 1768,
    "preview": "package ui\n\nimport (\n\t\"time\"\n\n\t\"charm.land/bubbles/v2/list\"\n\t\"charm.land/bubbles/v2/viewport\"\n\ttea \"charm.land/bubbletea"
  },
  {
    "path": "ui/msgs.go",
    "chars": 627,
    "preview": "package ui\n\ntype hideHelpMsg struct{}\n\ntype repoChosenMsg struct {\n\trepo string\n}\n\ntype prsFetchedMsg struct {\n\tprs []pr"
  },
  {
    "path": "ui/navigation.go",
    "chars": 4160,
    "preview": "package ui\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\nconst (\n\tmaxCommentsForNavIndicator = 8\n\tdisabledSectionMarker      = \"◌\"\n\tina"
  },
  {
    "path": "ui/pr_delegate.go",
    "chars": 372,
    "preview": "package ui\n\nimport (\n\t\"charm.land/bubbles/v2/list\"\n\t\"charm.land/lipgloss/v2\"\n)\n\nfunc newPRListItemDel() list.DefaultDele"
  },
  {
    "path": "ui/prtl_delegate.go",
    "chars": 378,
    "preview": "package ui\n\nimport (\n\t\"charm.land/bubbles/v2/list\"\n\t\"charm.land/lipgloss/v2\"\n)\n\nfunc newPRTLListItemDel() list.DefaultDe"
  },
  {
    "path": "ui/render_helpers.go",
    "chars": 6068,
    "preview": "package ui\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\thumanize \"github.com/dustin/go-humanize\"\n)\n\nconst (\n\twidthBudgetDefault    = 80"
  },
  {
    "path": "ui/repo_delegate.go",
    "chars": 378,
    "preview": "package ui\n\nimport (\n\t\"charm.land/bubbles/v2/list\"\n\t\"charm.land/lipgloss/v2\"\n)\n\nfunc newRepoListItemDel() list.DefaultDe"
  },
  {
    "path": "ui/styles.go",
    "chars": 3796,
    "preview": "package ui\n\nimport (\n\t\"hash/fnv\"\n\n\t\"charm.land/lipgloss/v2\"\n)\n\nconst (\n\tdefaultBackgroundColor      = \"#282828\"\n\trepoLis"
  },
  {
    "path": "ui/types.go",
    "chars": 17550,
    "preview": "package ui\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/dustin/go-humanize\"\n)\n\nconst (\n\tprStateOpen                "
  },
  {
    "path": "ui/ui.go",
    "chars": 450,
    "preview": "package ui\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\ttea \"charm.land/bubbletea/v2\"\n\tghapi \"github.com/cli/go-gh/v2/pkg/api\"\n)\n\nfunc Rende"
  },
  {
    "path": "ui/update.go",
    "chars": 23701,
    "preview": "package ui\n\nimport (\n\t_ \"embed\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"charm.land/bubbles/v2/list\"\n\t\"charm.land/bubbles/v2/viewp"
  },
  {
    "path": "ui/utils.go",
    "chars": 466,
    "preview": "package ui\n\nimport (\n\t\"strings\"\n)\n\nfunc RightPadTrim(s string, length int) string {\n\tif len(s) >= length {\n\t\tif length >"
  },
  {
    "path": "ui/view.go",
    "chars": 1723,
    "preview": "package ui\n\nimport (\n\t\"fmt\"\n\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n)\n\nconst (\n\tviewPortWrapUpperLimit"
  },
  {
    "path": "yamlfmt.yml",
    "chars": 45,
    "preview": "formatter:\n  retain_line_breaks_single: true\n"
  }
]

About this extraction

This page contains the full source code of the dhth/prs GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 45 files (125.8 KB), approximately 42.2k tokens, and a symbol index with 187 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!