linear-tui 0.13.0

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

## Setup

The Rust toolchain is pinned in `mise.toml`, so [mise](https://mise.jdx.dev/)
is all you need to install:

```sh
git clone https://github.com/k1-c/linear-tui.git
cd linear-tui
mise install       # fetch the pinned toolchain
mise run dev       # run the TUI from source
mise run verify    # format, lint, test, build
```

`mise run fmt`, `lint`, `test`, and `build` run the steps individually. Without
mise, any stable rustup toolchain works; `cargo run`, `cargo test`, and the
rest behave the same. The minimum supported Rust version is in `Cargo.toml`
(`rust-version`) and checked in CI.

No separate TLS dependencies are needed: TLS goes through rustls, so no system
OpenSSL or `pkg-config` is involved.

Before changing code, read [AGENTS.md](../AGENTS.md): the project map, the
architecture invariants (the UI thread never awaits; the layers only depend
inwards), the keybinding policy, and the commit and release flow. Behaviour
is specified in the use case layer, below. API types
follow [api-type-guide.md](api-type-guide.md).

## Checking a change by using it

After a change to what linear-tui shows or does, work the real program, not
only the tests. An agent does this the way a person would, through the
control channel:

```sh
cargo build
LINEAR_TUI_STATE_DIR=$(mktemp -d) ./target/debug/linear-tui --headless &   # or `mise run dev` in another terminal
./target/debug/linear-tui tui screen
./target/debug/linear-tui tui press "g m"
./target/debug/linear-tui tui run "Change status"
./target/debug/linear-tui tui quit
```

Use the same `LINEAR_TUI_STATE_DIR` for every command, so they find the
instance and it does not replace the view your own linear-tui reopens.
The instance acts on the Linear account you are signed in to: keys and
commands change issues for real, so change nothing you did not mean to.
The commands are in [cli.md](cli.md#linear-tui-tui-command---json---workspace-path).

## The use case layer

`src/core/usecase/` is the specification of linear-tui: what a person or an agent
can do, and the rules each thing follows. It is written to be read as such,
by people and by agents working on the code.

**Structure.** One module per aggregate (`issue`, `project`, `cycle`, `team`,
`view`, `favorite`, `user`, `notes`, `agent`, `instance`, `workspace`).
Everything done to an aggregate lives in its module — finding, reading, and changing an issue
are all in `issue.rs`. Never group use cases by kind of operation ("browse",
"search", "mutations"). `usecase/mod.rs` opens with a table of the modules.

**A use case** is a top-level `pub fn` taking the `Store` and explicit
arguments — which issue, which value — and returning its aggregate's
`Request` (or `Open` for a list, `Result<_, Refusal>` when it can decline, a
plain value for a rule). It never reads a cursor, a popup, or a screen, and
never performs I/O.

**Its doc comment is the specification.** It opens with the use case's name
in bold, as the user would say it, with the key when there is one; then its
rules in plain sentences — what happens, what is refused and why, what is
left alone:

```rust
/// **Browse a team's issues** in one of Linear's slices: Active, Backlog,
/// or All.
///
/// Linear slices the list, so a team's Active issues are all of them, not
/// the active ones among the latest page. A list already holding that slice
/// is not fetched again. Moving to another slice of the same team keeps the
/// rows on screen until the new slice lands; moving to another team drops
/// them.
pub fn open_team_issues(store: &mut Store, team_id: TeamId, preset: Preset) -> Open {
```

Write for someone who knows Linear but not this code: say "the issue shows
the new state everywhere at once", not "patches every copy in the store".

**The module's `//!` summary** is its table of contents: it links every use
case in the module.

**Its tests state the rules, one per test.** A `///` sentence above each
test says the rule; the test's name says it again as a sentence
(`a_status_change_shows_everywhere_at_once`, never `test_set_status`). Cover
what the doc comment promises, including every refusal and every "left
alone". Build the store a test needs in a small helper with a doc comment
saying what it holds.

`tests/usecase_spec.rs` enforces the shape: the table lists every module,
each summary links its use cases, each use case opens with its bold name and
has a test, and each test has its rule and a sentence for a name. Whether the
words say the right thing is for review.

**Every use case can be done from the command line.** An agent should not
need a TUI open to do what a person can in one, so each use case is served
by a headless command (`linear-tui issue …`, `project …`, `team …`, …, see
[cli.md](cli.md)). The command says so on a `Covers:` line in its doc
comment, as a scenario does:

```rust
/// `issue update`: any fields at once, a priority or an assignee (`me`)
/// among them.
///
/// Covers: issue::update, issue::set_priority, issue::set_assignee,
/// issue::assign_to_me
async fn update(linear: &impl Linear, args: &[String], now: u64) -> Result<String> {
```

What only exists on screen — keeping a thread in view, which team the TUI
opens on, notes a person writes for the agent — is listed with the reason
under "Not from the command line" in `src/interface/cli/mod.rs`.
`tests/usecase_spec.rs` fails for a use case that is neither; working the
running TUI (`linear-tui tui …`) does not count. So a new use case comes
with its command, or with the reason it needs none.

Around the use cases:

- In `src/core/`, `entity/` holds what they act on; `store/` keeps what
  Linear has told us consistent (including how every paginated list pages,
  in `lists.rs` — mechanism, not a use case).
- In `src/interface/`, the ways in call them: `tui/app/` resolves intents
  ("the issue under the cursor") and calls a use case, keeping no rule of
  its own beyond what the screen needs; `cli/` does the same for a
  subcommand, and `control/` drives `tui/` for an agent.
- In `src/infra/`, `dispatch` carries out the returned request against
  Linear or herdr.

## Tests

`cargo test` runs everything offline:

| Layer | What is tested | Where |
| --- | --- | --- |
| use cases | every rule of every use case: the specification | `src/core/usecase/*.rs` |
| store, entity | consistency (every copy patched, pages merged, stale pages dropped), value rules | `src/core/store/`, `src/core/entity/` |
| interface | intents and state transitions, rendering against ratatui's `TestBackend`, key and palette dispatch, the screen an agent reads, the CLI's output | `src/interface/` |
| infra | API decoding against `tests/fixtures/`, requests against a `wiremock` server, snapshot files | `src/infra/` |
| architecture | the layers depend inwards | `tests/architecture.rs` |
| specification | the use case layer's shape, that every use case can be done from the command line, and that every use case has an end-to-end scenario | `tests/usecase_spec.rs` |
| end to end | the real binary against real Linear workspaces, one scenario per use case at least | `tests/e2e/`, see below |

Spec coverage is measured on the use case layer:

```sh
mise run coverage        # line coverage by file, the use case layer first
mise run coverage:html   # a browsable report in target/llvm-cov/html
```

Keep `src/core/usecase/` near full coverage; a line no test reaches is a rule
nobody wrote down.

herdr-only bindings follow `App::herdr`, which `main` sets from
`HERDR_BIN_PATH`; tests build an `App` outside herdr unless they set it, so
the environment they run in does not matter.

The shell scripts of the plugins are checked with
`shellcheck -x -P SCRIPTDIR herdr-plugin/*.sh agent-plugin/hooks/*.sh`, and the
agent plugin with `claude plugin validate .` and
`claude plugin validate agent-plugin`.

## End-to-end tests

`tests/e2e/` runs the real binary, headless, against two real Linear
workspaces kept for it, and works it through `linear-tui tui …` exactly as an
agent would. Each scenario checks what the screen shows and what Linear ends
up holding (through `linear-tui issue show --json`). There is at least one
scenario per use case: each names the use cases it runs on a `Covers:` line,
and `tests/usecase_spec.rs` fails when a use case is neither covered nor
listed, with its reason, under "Not end to end" in `tests/e2e/main.rs`. The
rules themselves stay in the unit tests; a scenario only has to show the
representative path works against Linear.

**Every run empties both workspaces** — every issue, project, saved view,
and favorite — and seeds them again. Never point it at a workspace with
anything you want to keep; it refuses a workspace whose URL key is not the
one named for it.

### Setting up the workspaces

1. Create two Linear workspaces for the tests alone (the free plan will do),
   A and B.
2. Leave them as Linear makes them: the scenarios expect its default
   workflow states (Triage, Backlog, Todo, In Progress, In Review, Done,
   Canceled). The seed does the rest in A: it makes a second team
   (`E2E Second`, for the scenarios that switch team) when there is only
   one, and turns cycles on for the team it tests in. So the key must be
   an admin's — the workspace's creator's is.
3. In each, create a personal API key (Settings › Security & access ›
   Personal API keys).
4. Add these repository secrets (Settings › Secrets and variables ›
   Actions):

   | Secret | Value |
   | --- | --- |
   | `LINEAR_E2E_API_KEY_A` | A's API key |
   | `LINEAR_E2E_WORKSPACE_A` | A's URL key: the `acme` in `linear.app/acme/…` |
   | `LINEAR_E2E_API_KEY_B` | B's API key |
   | `LINEAR_E2E_WORKSPACE_B` | B's URL key |
   | `LINEAR_E2E_TEAM_A`, `LINEAR_E2E_TEAM_B` | optional: the team key to test in, instead of the first team |

`.github/workflows/e2e.yml` then runs them daily, on demand, on
release-plz's release PR, and on a pull request labelled `e2e` — one run at
a time. Not on every pull request: a run takes minutes, waits behind every
other, and resets the workspaces under anyone running them locally. Without
the secrets (a fork, or before setup) the job says so and passes.

### Running them locally

Put the same variables in `mise.local.toml` at the root of the checkout.
It is git-ignored, and mise reads it for every task:

```toml
[env]
LINEAR_E2E_API_KEY_A = "lin_api_…"
LINEAR_E2E_WORKSPACE_A = "<A's URL key>"
LINEAR_E2E_API_KEY_B = "lin_api_…"
LINEAR_E2E_WORKSPACE_B = "<B's URL key>"
```

Then one command runs them, in about two minutes:

```sh
mise run e2e     # cargo test --test e2e -- --ignored --test-threads=1
```

Run them before merging a change to what linear-tui shows or does; CI does
not run them on the pull request unless it is labelled `e2e`. Only one run
may use the workspaces at a time, so do not run them while a CI run is in
progress (`gh run list --workflow e2e.yml`), nor two locally. A plain `cargo test` never touches Linear: the
scenarios are `#[ignore]`d.

## Documentation

| When you change | Update |
| --- | --- |
| what a user or an agent can do | the use case in `src/core/usecase/<aggregate>.rs`: its doc comment and its tests (see [The use case layer]#the-use-case-layer); its command in `src/interface/cli/`, with a `Covers:` line |
| a keybinding | the `BINDINGS` row in `src/interface/tui/keys.rs`, and [keybindings.md]keybindings.md (with the "Differences from Linear" table if it departs from Linear) |
| a `config.toml` key | `KNOWN_KEYS` in `src/config.rs`, and [configuration.md]configuration.md |
| a subcommand or its output | [cli.md]cli.md, a contract for agents and scripts |
| the view snapshot | [view-snapshot.md]view-snapshot.md, `snapshot::VERSION` for a breaking change |
| the herdr plugin | [herdr.md]herdr.md, and `version` in `herdr-plugin/herdr-plugin.toml` |
| the agent plugin | [agent-plugin.md]agent-plugin.md, and `version` in both of its `plugin.json` |
| what users see first | the README, and a demo if the feature is worth showing |

## Demos

The GIFs in `assets/` are scripted with [VHS](https://github.com/charmbracelet/vhs),
so they can be re-recorded after a UI change. They run against a throwaway
Linear workspace filled with made-up data for a weather app, not a real one.

| Tape | GIF | Shows |
| --- | --- | --- |
| `demo/demo.tape` | `assets/demo.gif` | The tour: grouped lists, presets, an issue with Markdown and comments, a project, a cycle, a saved view, the sidebar |
| `demo/palette.tape` | `assets/palette.gif` | The command palette: commands, issue search, pickers, places |
| `demo/resume.tape` | `assets/resume.gif` | Quitting a few pages deep and relaunching where you left off |
| `demo/agents.tape` | `assets/agents.gif` | linear-tui beside an agent's shell (tmux, `demo/tmux.conf`): notes on what you read, and what the agent sees with `linear-tui context` and `issue show` |

Sign linear-tui in to the throwaway workspace (or use an API key for it), fill
it with demo data once, then record:

```sh
python3 demo/seed.py                                    # issues, projects, cycles, views
LINEAR_DEMO_TEAM=<team name> demo/record.sh             # assets/demo.gif
LINEAR_DEMO_TEAM=<team name> demo/record.sh all         # every tape
LINEAR_DEMO_API_KEY=lin_api_... demo/record.sh all      # with an API key instead
```

`record.sh` needs `vhs`, `ttyd`, and `ffmpeg` (and `tmux` for `agents.tape`),
and runs on Linux. linear-tui runs with a throwaway config and a fresh state
directory per tape, so neither your settings nor a remembered view leak into a
recording, and no tape leaves one behind. It also drops herdr's environment,
so a recording made inside herdr still shows linear-tui as it runs elsewhere. The tapes change nothing in the
workspace: pickers are closed without choosing, and notes go to the clipboard.

A new tape follows the same pattern: launch off camera (`Hide` … `Show`), wait
for text only the loaded page shows (`Wait+Screen /…/`) rather than sleeping on
API latency, and keep it under 30 seconds with one feature per GIF.

## Releases

Releases are automated by release-plz; see [AGENTS.md](../AGENTS.md#commits-prs-and-releases).
The herdr and agent plugins are installed from the repository, not from the
crate, so a change to them alone needs no release.