linear-tui 0.3.0

A TUI client for Linear.app — manage issues, projects, and cycles from your terminal
# linear-tui

Linear.app TUI client written in Rust, built on ratatui, crossterm, tokio, and
the Linear GraphQL API.

## Build & Check Commands

```bash
cargo fmt --all                            # format
cargo clippy --all-targets -- -D warnings  # lint
cargo test                                 # test
cargo build                                # build
cargo run                                  # run (requires auth setup first)
```

Run all four, in that order, after any implementation task.

## Project Structure

- `src/main.rs` — entry point, CLI subcommands, TUI main loop, request dispatch
- `src/message.rs` — `Request` / `Message` / `Page`, the boundary between UI and I/O
- `src/app.rs` — app state (Model) and every state transition
- `src/keys.rs` — keybindings (Controller)
- `src/event.rs` — terminal event polling
- `src/ui/` — rendering (View): `issue_list`, `issue_detail`, `project_list`,
  `project_detail`, `cycle_list`, `cycle_detail`, `popup`, `new_issue`
- `src/api/` — Linear GraphQL client and types (see `docs/api-type-guide.md`)
- `tests/fixtures/` — API response fixtures for deserialization tests
- `src/auth/` — OAuth2 + PKCE, token storage, API key fallback
- `src/config.rs` — config file + theme (`~/.config/linear-tui/config.toml`)

## Architecture

**The UI thread never awaits.** Network work goes through a message loop:

```
keys/app  →  App::request(Request)  →  main loop spawns onto tokio
                                              ↓
App::handle_message(Message)  ←  mpsc channel  ←  execute_request
```

Adding an API call means adding a `Request` variant, a `Message` variant, and an
arm in `execute_request` — never an `.await` inside the main loop, `ui/`, or
`keys.rs`. An inline await freezes input and animation for the whole request.

Other invariants:

- `ui::draw` takes `&mut App` so renderers can write back measurements
  (`detail_lines`, `list_viewport`, table offsets). Rendering must not do I/O.
- Mutations are optimistic. `App::patch_issue` updates every copy of an issue,
  including `current_issue`, and the request only confirms it. Do not trigger a
  full list reload to reflect a single-field change — it costs a round trip and
  throws away the cursor position.
- A refetch restores the selection by issue id, not by row index.
- Every paginated list has a `*_page_info` field and a `maybe_prefetch_*` helper;
  wire new lists into `App::move_selection` so they scroll infinitely too.

## Keybindings

Shortcuts mirror [Linear's own](https://linear.app/docs). Before adding or
changing one, check what Linear binds that key to.

- Where a terminal cannot deliver Linear's key (`Ctrl`+punctuation, `Ctrl+M`),
  support the original under the kitty keyboard protocol — enabled automatically
  in `TerminalGuard::enter` — and add a plain-key alias that collides with
  nothing in Linear.
- Record every intentional deviation in the README's "Differences from Linear"
  table, with the reason.

## API Types

Follow `docs/api-type-guide.md`. Linear's introspection endpoint answers without
authentication, so schema questions can be settled directly:

```bash
curl -s https://api.linear.app/graphql -H 'Content-Type: application/json' \
  -d '{"query":"{ __type(name: \"Issue\") { fields { name } } }"}'
```

- Fields shared by every issue query live in `ISSUE_FIELDS` in
  `src/api/client.rs`. Extend that constant rather than one query's selection.
- Always add or update a fixture in `tests/fixtures/` plus a deserialization
  test, including a case where the new field is absent.

## Commits, PRs, and Releases

- Branch off `main`; never commit directly to `main`.
- Use [Conventional Commits]https://www.conventionalcommits.org/. PR titles are
  validated by `.github/workflows/pr-title.yml`, so the **PR title must be
  conventional too**.
- No AI attribution in commit messages or PR bodies — no `Co-Authored-By`
  trailer, no "Generated with ..." line.

Releases are automated by [release-plz](https://release-plz.dev/):

1. Merge a PR into `main`.
2. release-plz opens a `chore: release vX.Y.Z` PR that bumps `Cargo.toml`,
   `Cargo.lock`, and `CHANGELOG.md`.
3. Merging *that* PR creates the tag, publishes to crates.io, and uploads
   binaries for linux-gnu, apple-darwin (x86_64 + aarch64), and windows-msvc.

**Never edit the version in `Cargo.toml` by hand** — release-plz owns it, and a
manual bump collides with its PR. The bump is derived from commit types: `fix` →
patch, `feat` → minor, and `!` / `BREAKING CHANGE` → minor while the crate is
still `0.x`.

A crates.io publish cannot be undone, only yanked. Run the app against a real
Linear workspace before merging a release PR.