docs/plans/2026-10-01-cli-views.md

main
gitbay/docs/plans/2026-10-01-cli-views.md rendered · source · history · blame · raw

2553 lines · 84575 bytes

24 symbols in this file
   1# CLI views implementation plan (stages 1 and 2)
   2
   3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
   4
   5**Goal:** Terminal output drawn as magit-style screens (header block, body, counted sections, action legend), with every unmigrated list restyled through the same row renderer.
   6
   7**Architecture:** A declarative `screen` value built by a command at a terminal and drawn by one renderer in `internal/control/screen.go`. The row layout reuses `table`'s width logic; `table` itself drops its header row and cyan. `emitView` and `emitPageView` route `--json` and piped output to the existing code unchanged. The CLI tells the server which repository it inferred (`here=` in `--term`) so legend commands can omit it.
   8
   9**Tech Stack:** Go, the `internal/control` registry, `internal/termtext` for markup, `git` via `internal/gitutil`.
  10
  11**Spec:** `docs/specs/2026-10-01-cli-views-design.md`
  12
  13**Issues:** #316 (tracking), #317 (stage 1), #318 (stage 2), #319 (stage 3)
  14
  15## Global Constraints
  16
  17- Piped output and `--json` are byte-identical before and after every task.
  18- All rendering is server-side in `internal/control`; `cmd/gitbay` only adds `here=` to `--term`.
  19- Colour is never the only signal; `stripSGR` of a colour render equals the colourless render.
  20- Colours: blue (`\x1b[34m`) for section headings and legend commands; yellow for what waits on the viewer; green/red/none for state. Refs are dim, never cyan.
  21- Glyphs: `✓` passed/ok, `✗` failed/blocked, `◐` running/pending, `●` waiting on you, `○` closed/draft.
  22- Commit messages end with `Ref #N` / `Closes #N` for the stage issue. No attribution lines.
  23- Locally: `go build ./...`, `go vet ./...`, `go test ./internal/control/ ./internal/gitutil/ ./cmd/gitbay/`. The e2e suite runs in CI.
  24- Commits are signed (the repository requires it); MRs merge with `--strategy ff`.
  25
  26## Corrections to the spec found while planning
  27
  28These are applied to the spec in the same commit as this plan:
  29
  301. There is no `browse` command. The legend never offers one; the header's
  31   first field links to the page (OSC 8) where the terminal shows links,
  32   and a `URL:` field carries it otherwise.
  332. `mr rebase`, `mr checkout` and `repo clone` are CLI-local (`cmd/gitbay/local.go`),
  34   not registry commands. The legend may name them; the resolve test
  35   accepts them from a fixed list.
  363. The server cannot see the client's working directory. The CLI sends
  37   `here=<owner/name>` in `--term` when it is inside a clone it can infer;
  38   legend commands drop an argument equal to it.
  394. Actions carry no label: the legend prints group names and commands.
  405. A table that has not migrated keeps its dim header row when it has a
  41   number or size column (`admin runners`, `admin stats`): without a
  42   header those columns are unreadable. Every other table drops it.
  436. `mr show` commits carry no signature glyph: `CommitOut` has no
  44   signature state, and verifying each commit on every show is not worth
  45   the cost. `repo log` already shows it.
  467. Discussion rows carry their full body beneath them. A show screen that
  47   printed only first lines would lose what `issue show` prints today.
  48
  49## File structure
  50
  51| File | Responsibility |
  52|---|---|
  53| `internal/control/screen.go` (new) | `screen`, `field`, `section`, `row`, `action`; `render`; legend layout; `cmdline` |
  54| `internal/control/screen_test.go` (new) | renderer goldens, legend layout, `checkActions` helper |
  55| `internal/control/plain_test.go` (new) | `pinPlain` golden helper for piped output |
  56| `internal/control/testdata/plain/*.txt` (new) | pinned piped output per migrated command |
  57| `internal/control/table.go` | new cell kinds, header rule, dim refs, `lines()` split from `flush()` |
  58| `internal/control/term.go` | `sgrBlue`, `glyph()`, `Term.Here` |
  59| `internal/control/view.go` | ref painted dim in `title` |
  60| `internal/control/control.go` | `emitView` |
  61| `internal/control/cursor.go` | `emitPageView`, `nextArgv`, restyled next-page line |
  62| `internal/gitutil/numstat.go` (new) | `DiffNumstat` |
  63| `internal/control/mr.go`, `issue.go`, `repo.go`, `build.go`, `dashboard.go` | screen builders and `emitView` calls |
  64| `cmd/gitbay/ssh.go` | `here=` |
  65| `.gitbay/wiki/Users.org` | "Output rules" rewritten |
  66| `CHANGELOG.org` | unreleased entries |
  67
  68---
  69
  70## Stage 1 (one MR, Ref #317)
  71
  72### Task 1: Palette, glyph cells, dim refs
  73
  74**Files:**
  75- Modify: `internal/control/term.go` (const block at line 53, add `glyph`)
  76- Modify: `internal/control/table.go` (cell kinds, constructors, `joinRow`, `fit`)
  77- Modify: `internal/control/view.go:99,106`
  78- Test: `internal/control/term_test.go`, `internal/control/table_test.go`
  79
  80**Interfaces:**
  81- Produces: `sgrBlue`; `glyph(state string) (g, sgr string)`; `kindGlyph`, `kindMeta`; `cGlyph(state string) cell`; `cYou() cell`; `cMeta(parts ...string) cell`.
  82
  83- [ ] **Step 1: Write failing tests**
  84
  85Append to `internal/control/term_test.go`:
  86
  87```go
  88func TestGlyph(t *testing.T) {
  89	for _, c := range []struct{ state, g, sgr string }{
  90		{"success", "✓", sgrGreen},
  91		{"approved", "✓", sgrGreen},
  92		{"merged", "✓", ""},
  93		{"failure", "✗", sgrRed},
  94		{"changes requested", "✗", sgrRed},
  95		{"signed_key_revoked", "✗", sgrRed},
  96		{"running", "◐", ""},
  97		{"pending", "◐", ""},
  98		{"closed", "○", ""},
  99		{"draft", "○", ""},
 100		{"open", "", ""},
 101		{"", "", ""},
 102	} {
 103		g, sgr := glyph(c.state)
 104		if g != c.g || sgr != c.sgr {
 105			t.Errorf("glyph(%q) = %q %q, want %q %q", c.state, g, sgr, c.g, c.sgr)
 106		}
 107	}
 108}
 109```
 110
 111Append to `internal/control/table_test.go`:
 112
 113```go
 114func TestRefsDimNotCyan(t *testing.T) {
 115	var b strings.Builder
 116	tb := (&Ctx{Term: Term{Cols: 80, Color: true}}).table(&b, "#", "TITLE")
 117	tb.row(cRef("#12"), cFlex("Android app"))
 118	tb.flush()
 119	if strings.Contains(b.String(), sgrCyan) {
 120		t.Errorf("ref painted cyan: %q", b.String())
 121	}
 122	if !strings.Contains(b.String(), sgrDim+"#12"+sgrReset) {
 123		t.Errorf("ref not dim: %q", b.String())
 124	}
 125}
 126
 127func TestGlyphAndMetaCells(t *testing.T) {
 128	var b strings.Builder
 129	tb := (&Ctx{Term: Term{Cols: 80, Color: true}}).table(&b)
 130	tb.row(cRef("1779"), cGlyph("failure"), cFlex("test"), cMeta("gitbay", "2h"))
 131	tb.row(cRef("1780"), cYou(), cFlex("review"), cMeta("", "3h"))
 132	tb.flush()
 133	out := b.String()
 134	for _, want := range []string{sgrRed + "✗" + sgrReset, sgrYellow + "●" + sgrReset, sgrDim + "gitbay · 2h" + sgrReset, sgrDim + "3h" + sgrReset} {
 135		if !strings.Contains(out, want) {
 136			t.Errorf("missing %q in %q", want, out)
 137		}
 138	}
 139}
 140```
 141
 142- [ ] **Step 2: Run to confirm failure**
 143
 144Run: `go test ./internal/control/ -run 'TestGlyph|TestRefsDimNotCyan|TestGlyphAndMetaCells'`
 145Expected: build failure, `undefined: glyph`, `undefined: cGlyph`.
 146
 147- [ ] **Step 3: Implement**
 148
 149In `term.go`, add `sgrBlue = "\x1b[34m"` to the const block after `sgrYellow`, and after `stateColor`:
 150
 151```go
 152// glyph is the mark a screen puts first in a row or field for a state
 153// word, and its colour: ✓ passed, ✗ failed or blocked, ◐ still going,
 154// ○ closed or draft. A state with no mark ("open") returns "".
 155func glyph(state string) (g, sgr string) {
 156	switch state {
 157	case "merged":
 158		return "✓", ""
 159	case "success", "ok", "approved", "verified", "passed":
 160		return "✓", sgrGreen
 161	case "failure", "failed", "error", "changes requested",
 162		"bad_signature", "signed_email_mismatch", "signed_key_expired", "signed_key_revoked":
 163		return "✗", sgrRed
 164	case "pending", "running", "queued":
 165		return "◐", ""
 166	case "closed", "draft", "canceled", "cancelled", "skipped":
 167		return "○", ""
 168	}
 169	return "", ""
 170}
 171```
 172
 173In `table.go`, extend the kind list and constructors:
 174
 175```go
 176const (
 177	kindText cellKind = iota
 178	kindFlex
 179	kindRef
 180	kindState
 181	kindAge
 182	kindNum
 183	kindSize
 184	kindSwatch
 185	kindGlyph
 186	kindMeta
 187)
 188```
 189
 190```go
 191// cGlyph is a state's mark, coloured for the state.
 192func cGlyph(state string) cell {
 193	g, sgr := glyph(state)
 194	return cell{kind: kindGlyph, s: g, sgr: sgr}
 195}
 196
 197// cYou is the mark for a row that waits on the viewer.
 198func cYou() cell { return cell{kind: kindGlyph, s: "●", sgr: sgrYellow} }
 199
 200// cMeta is a row's trailing facts, dim and joined by " · ". Empty parts
 201// are skipped.
 202func cMeta(parts ...string) cell {
 203	var keep []string
 204	for _, p := range parts {
 205		if p != "" {
 206			keep = append(keep, p)
 207		}
 208	}
 209	return cell{kind: kindMeta, s: strings.Join(keep, " · ")}
 210}
 211```
 212
 213In `joinRow`, replace the `kindRef` case and add two cases:
 214
 215```go
 216			case kindRef:
 217				s = t.term.link(r[i].url, t.term.paint(sgrDim, s))
 218			case kindGlyph:
 219				s = t.term.paint(r[i].sgr, s)
 220			case kindMeta:
 221				s = t.term.paint(sgrDim, s)
 222```
 223
 224In `fit`, let metadata shrink with text:
 225
 226```go
 227	for i := len(kinds) - 1; i >= 0; i-- {
 228		if kinds[i] == kindText || kinds[i] == kindMeta {
 229			shrink(i)
 230		}
 231	}
 232```
 233
 234In `view.go`, lines 99 and 106, replace `sgrCyan` with `sgrDim`.
 235
 236- [ ] **Step 4: Run tests**
 237
 238Run: `go test ./internal/control/`
 239Expected: the three new tests pass. Any existing test asserting `sgrCyan` on a ref (search: `grep -n sgrCyan internal/control/*_test.go`) is updated to `sgrDim` in this step; diff and tree tests that expect cyan hunk headers and directory names stay as they are.
 240
 241- [ ] **Step 5: Commit**
 242
 243```bash
 244git add internal/control/term.go internal/control/table.go internal/control/view.go internal/control/*_test.go
 245git commit -m "control: glyph and meta cells, refs dim
 246
 247Ref #317"
 248```
 249
 250### Task 2: Header rows only where columns need naming
 251
 252**Files:**
 253- Modify: `internal/control/table.go` (`flush`, new `numeric`, new `lines`)
 254- Test: `internal/control/table_test.go`
 255
 256**Interfaces:**
 257- Produces: `func (t *table) lines() []string` (terminal rows, padded and painted, header omitted); `flush` keeps its signature.
 258
 259- [ ] **Step 1: Write failing tests**
 260
 261```go
 262func TestTableDropsHeaderWithoutNumbers(t *testing.T) {
 263	var b strings.Builder
 264	tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "#", "STATE", "TITLE")
 265	tb.row(cRef("#12"), cState("open"), cFlex("Android app"))
 266	tb.flush()
 267	if got, want := b.String(), "#12  open  Android app\n"; got != want {
 268		t.Errorf("got %q, want %q", got, want)
 269	}
 270}
 271
 272func TestTableKeepsHeaderForNumbers(t *testing.T) {
 273	var b strings.Builder
 274	tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "RUNNER", "PENDING")
 275	tb.row(cText("bay1"), cNum(3))
 276	tb.flush()
 277	if !strings.HasPrefix(b.String(), "RUNNER  PENDING\n") {
 278		t.Errorf("header missing: %q", b.String())
 279	}
 280}
 281
 282func TestTableLines(t *testing.T) {
 283	tb := (&Ctx{Term: Term{Cols: 80}}).table(io.Discard)
 284	tb.row(cRef("!1"), cFlex("one"))
 285	tb.row(cRef("!22"), cFlex("two"))
 286	got := tb.lines()
 287	want := []string{"!1   one", "!22  two"}
 288	if !slices.Equal(got, want) {
 289		t.Errorf("got %q, want %q", got, want)
 290	}
 291}
 292```
 293
 294Add `"io"` and `"slices"` to the test file's imports if missing.
 295
 296- [ ] **Step 2: Run to confirm failure**
 297
 298Run: `go test ./internal/control/ -run 'TestTableDropsHeader|TestTableKeepsHeader|TestTableLines'`
 299Expected: FAIL (header printed; `lines` undefined).
 300
 301- [ ] **Step 3: Implement**
 302
 303Replace `flush` with:
 304
 305```go
 306func (t *table) flush() {
 307	if t.term.Cols == 0 || len(t.rows) == 0 {
 308		return
 309	}
 310	var b strings.Builder
 311	for _, l := range t.lines() {
 312		b.WriteString(l + "\n")
 313	}
 314	io.WriteString(t.w, b.String())
 315}
 316
 317// numeric reports whether a column holds numbers or sizes, which need a
 318// header to say what they count.
 319func (t *table) numeric() bool {
 320	for _, r := range t.rows {
 321		for _, c := range r {
 322			if c.kind == kindNum || c.kind == kindSize {
 323				return true
 324			}
 325		}
 326	}
 327	return false
 328}
 329
 330// lines lays the rows out at the terminal width: a dim header first
 331// only when a column is a number, then each row padded and painted.
 332func (t *table) lines() []string {
 333	if !t.numeric() {
 334		t.header = nil
 335	}
 336	t.dropEmpty()
 337	n := len(t.header)
 338	for _, r := range t.rows {
 339		n = max(n, len(r))
 340	}
 341	widths := make([]int, n)
 342	for i, h := range t.header {
 343		widths[i] = cells(h)
 344	}
 345	for _, r := range t.rows {
 346		for i := 0; i < len(r); i++ {
 347			widths[i] = max(widths[i], cells(r[i].s))
 348		}
 349	}
 350	t.capSparse(widths)
 351	t.fit(widths)
 352
 353	var out []string
 354	line := make([]string, n)
 355	if len(t.header) > 0 {
 356		for i := range line {
 357			line[i] = ""
 358			if i < len(t.header) {
 359				line[i] = clip(t.header[i], widths[i])
 360			}
 361		}
 362		out = append(out, t.term.paint(sgrDim, strings.TrimRight(t.join(line, widths), " ")))
 363	}
 364	for _, r := range t.rows {
 365		for i := 0; i < n; i++ {
 366			s := ""
 367			if i < len(r) {
 368				s = clip(r[i].s, widths[i])
 369			}
 370			line[i] = s
 371		}
 372		out = append(out, strings.TrimRight(t.joinRow(r, line, widths), " "))
 373	}
 374	return out
 375}
 376```
 377
 378Update the comment above `type table struct` to say the header is written only when a column is numeric.
 379
 380- [ ] **Step 4: Run tests**
 381
 382Run: `go test ./internal/control/`
 383Expected: new tests pass. Existing terminal tests that expected a header line on a table without numbers fail; update each expected string to drop its first line (the header). Do not change any plain-output expectation.
 384
 385- [ ] **Step 5: Commit**
 386
 387```bash
 388git add internal/control/table.go internal/control/*_test.go
 389git commit -m "control: tables name their columns only when they hold numbers
 390
 391Ref #317"
 392```
 393
 394### Task 3: The screen model and renderer
 395
 396**Files:**
 397- Create: `internal/control/screen.go`
 398- Create: `internal/control/screen_test.go`
 399
 400**Interfaces:**
 401- Consumes: `table.lines()`, `cGlyph`, `cMeta`, `cYou`, `sgrBlue` (tasks 1–2).
 402- Produces:
 403
 404```go
 405type screen struct {
 406	fields   []field
 407	body     string
 408	format   string
 409	sections []section
 410	actions  []action
 411}
 412type field struct {
 413	label string
 414	value []cell
 415}
 416type section struct {
 417	title string
 418	n     int
 419	rows  []row
 420	more  []string
 421	empty bool
 422}
 423type row struct {
 424	cells  []cell
 425	body   string
 426	format string
 427}
 428type action struct {
 429	group string
 430	argv  []string
 431}
 432func (c *Ctx) render(w io.Writer, s screen)
 433func rowOf(cs ...cell) row // a row with no body
 434```
 435
 436- [ ] **Step 1: Write failing tests**
 437
 438`internal/control/screen_test.go`:
 439
 440```go
 441package control
 442
 443import (
 444	"strings"
 445	"testing"
 446	"time"
 447)
 448
 449func screenCtx(cols int, color bool) *Ctx {
 450	return &Ctx{Term: Term{Cols: cols, Color: color}}
 451}
 452
 453func renderString(c *Ctx, s screen) string {
 454	var b strings.Builder
 455	c.render(&b, s)
 456	return b.String()
 457}
 458
 459func sampleScreen() screen {
 460	return screen{
 461		fields: []field{
 462			{"Merge", []cell{cRef("!552"), cText("wire $PAGER through long views")}},
 463			{"State", []cell{cState("open"), cMeta("cli-pager → main", "cmc")}},
 464			{"Checks", []cell{cGlyph("running"), cText("1 running")}},
 465		},
 466		body:   "Pages long views.",
 467		format: "markdown",
 468		sections: []section{
 469			{title: "Commits", n: 2, rows: []row{
 470				rowOf(cRef("8f3a1c2"), cFlex("cli: page long output")),
 471				rowOf(cRef("2b77e90"), cFlex("control: mark views")),
 472			}},
 473			{title: "Discussion", n: 0, empty: true},
 474			{title: "Hidden", n: 0},
 475		},
 476	}
 477}
 478
 479func TestRenderPlainLayout(t *testing.T) {
 480	got := renderString(screenCtx(80, false), sampleScreen())
 481	want := `Merge:   !552  wire $PAGER through long views
 482State:   open  cli-pager → main · cmc
 483Checks:  ◐  1 running
 484
 485Pages long views.
 486
 487Commits (2)
 4888f3a1c2  cli: page long output
 4892b77e90  control: mark views
 490
 491Discussion (0)
 492`
 493	if got != want {
 494		t.Errorf("got:\n%s\nwant:\n%s", got, want)
 495	}
 496}
 497
 498func TestRenderColourIsOnlyPaint(t *testing.T) {
 499	for _, cols := range []int{80, 120} {
 500		plain := renderString(screenCtx(cols, false), sampleScreen())
 501		colour := renderString(screenCtx(cols, true), sampleScreen())
 502		if stripSGR(colour) != plain {
 503			t.Errorf("cols %d: colour render differs beyond SGR:\n%s\n---\n%s", cols, stripSGR(colour), plain)
 504		}
 505		if !strings.Contains(colour, sgrBold+sgrBlue+"Commits (2)"+sgrReset) {
 506			t.Errorf("cols %d: heading not bold blue: %q", cols, colour)
 507		}
 508		if !strings.Contains(colour, sgrDim+"Merge:"+sgrReset) {
 509			t.Errorf("cols %d: label not dim: %q", cols, colour)
 510		}
 511	}
 512}
 513
 514func TestRenderMoreLine(t *testing.T) {
 515	s := screen{sections: []section{{title: "Builds", n: 14, more: []string{"build", "list", "krz/gitbay"},
 516		rows: []row{rowOf(cRef("1779"), cFlex("test"))}}}}
 517	got := renderString(screenCtx(80, false), s)
 518	if !strings.HasSuffix(got, "1779  test\n+13 more  gitbay build list krz/gitbay\n") {
 519		t.Errorf("got %q", got)
 520	}
 521}
 522
 523func TestRenderRowBody(t *testing.T) {
 524	s := screen{sections: []section{{title: "Discussion", n: 1, rows: []row{
 525		{cells: []cell{cText("cmc"), cMeta("2h")}, body: "Looks good.", format: "markdown"},
 526	}}}}
 527	got := renderString(screenCtx(80, false), s)
 528	want := "Discussion (1)\ncmc  2h\n  Looks good.\n"
 529	if got != want {
 530		t.Errorf("got %q, want %q", got, want)
 531	}
 532}
 533
 534func TestRenderFieldWraps(t *testing.T) {
 535	long := strings.Repeat("word ", 20)
 536	s := screen{fields: []field{{"Merge", []cell{cRef("!1"), cText(strings.TrimSpace(long))}}}}
 537	for _, l := range strings.Split(strings.TrimRight(renderString(screenCtx(40, false), s), "\n"), "\n") {
 538		if cells(l) > 40 {
 539			t.Errorf("line wider than 40: %q", l)
 540		}
 541	}
 542}
 543
 544func init() { termNow = func() time.Time { return time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) } }
 545```
 546
 547If `termNow` is already pinned by another test file's `init`, drop the `init` here.
 548
 549- [ ] **Step 2: Run to confirm failure**
 550
 551Run: `go test ./internal/control/ -run 'TestRender'`
 552Expected: build failure, `undefined: screen`.
 553
 554- [ ] **Step 3: Implement `internal/control/screen.go`**
 555
 556```go
 557package control
 558
 559import (
 560	"fmt"
 561	"io"
 562	"strings"
 563
 564	"gitbay.org/krz/gitbay/internal/termtext"
 565)
 566
 567// screen is a show or a list as a terminal draws it: a header block of
 568// labelled fields, a body, counted sections, and the commands that
 569// apply. Commands build one only at a terminal; plain and --json output
 570// never reach it.
 571type screen struct {
 572	fields   []field
 573	body     string // markup source
 574	format   string // the body's markup format
 575	sections []section
 576	actions  []action
 577}
 578
 579// field is one header line: a label and the cells after it.
 580type field struct {
 581	label string
 582	value []cell
 583}
 584
 585// section is a titled run of rows. n is the total, shown as "(n)"; when
 586// it is more than the rows shown, more is the command for the rest. An
 587// empty section is left out unless empty says to draw "(0)".
 588type section struct {
 589	title string
 590	n     int
 591	rows  []row
 592	more  []string
 593	empty bool
 594}
 595
 596// row is a section row, and optionally the markup that follows it (a
 597// comment's body).
 598type row struct {
 599	cells  []cell
 600	body   string
 601	format string
 602}
 603
 604func rowOf(cs ...cell) row { return row{cells: cs} }
 605
 606// action is one command in the legend, under its group's name. argv is
 607// the command as typed after "gitbay".
 608type action struct {
 609	group string
 610	argv  []string
 611}
 612
 613// render draws s: the parts in order, one blank line between parts and
 614// between sections.
 615func (c *Ctx) render(w io.Writer, s screen) {
 616	var blocks []string
 617	if b := c.renderFields(s.fields); b != "" {
 618		blocks = append(blocks, b)
 619	}
 620	if b := c.renderBody(s.body, s.format, ""); b != "" {
 621		blocks = append(blocks, b)
 622	}
 623	for _, sec := range s.sections {
 624		if b := c.renderSection(sec); b != "" {
 625			blocks = append(blocks, b)
 626		}
 627	}
 628	if b := c.renderLegend(s.actions); b != "" {
 629		blocks = append(blocks, b)
 630	}
 631	io.WriteString(w, strings.Join(blocks, "\n"))
 632}
 633
 634// renderFields aligns labels on the widest, dim, with a colon. Every
 635// cell but the last is painted as a table paints it; the last wraps to
 636// the width, continuation lines under its first column.
 637func (c *Ctx) renderFields(fs []field) string {
 638	t := c.Term
 639	wide := 0
 640	for _, f := range fs {
 641		if len(f.value) > 0 {
 642			wide = max(wide, cells(f.label)+1)
 643		}
 644	}
 645	var b strings.Builder
 646	for _, f := range fs {
 647		if len(f.value) == 0 {
 648			continue
 649		}
 650		prefix := t.paint(sgrDim, f.label+":") + strings.Repeat(" ", wide-cells(f.label)-1+2)
 651		col := wide + 2
 652		head := ""
 653		for _, cl := range f.value[:len(f.value)-1] {
 654			s := c.cellText(cl)
 655			if s == "" {
 656				continue
 657			}
 658			head += c.paintCell(cl, s) + "  "
 659			col += cells(s) + 2
 660		}
 661		last := f.value[len(f.value)-1]
 662		lines := termtext.Wrap(c.cellText(last), max(8, t.Cols-col))
 663		indent := strings.Repeat(" ", col)
 664		for i, l := range lines {
 665			p := indent
 666			if i == 0 {
 667				p = prefix + head
 668			}
 669			b.WriteString(strings.TrimRight(p+c.paintCell(last, l), " ") + "\n")
 670		}
 671	}
 672	return b.String()
 673}
 674
 675// cellText is a cell's terminal text: made safe, ages relative, sizes
 676// humanized, as table.row prepares it.
 677func (c *Ctx) cellText(cl cell) string {
 678	s := termSafe(cl.s)
 679	switch cl.kind {
 680	case kindAge:
 681		return relAge(s, termNow())
 682	case kindSize:
 683		var n int64
 684		if _, err := fmt.Sscan(s, &n); err == nil {
 685			return humanBytes(n)
 686		}
 687	}
 688	return s
 689}
 690
 691// paintCell paints text s as its cell's kind paints at a terminal.
 692func (c *Ctx) paintCell(cl cell, s string) string {
 693	t := c.Term
 694	switch cl.kind {
 695	case kindState:
 696		return t.paintState(s)
 697	case kindRef:
 698		return t.link(cl.url, t.paint(sgrDim, s))
 699	case kindMeta:
 700		return t.paint(sgrDim, s)
 701	case kindSwatch:
 702		return t.swatch(s)
 703	}
 704	return t.paint(cl.sgr, s)
 705}
 706
 707// renderBody renders markup to the width, each line prefixed by indent.
 708func (c *Ctx) renderBody(src, format, indent string) string {
 709	if strings.TrimSpace(src) == "" {
 710		return ""
 711	}
 712	opts := termtext.Options{Width: max(0, c.Term.Cols-len(indent)), Color: c.Term.Color, Base: c.Cfg.Server.SiteURL}
 713	var b strings.Builder
 714	for _, l := range strings.Split(strings.TrimRight(termtext.Render(termSafe(src), format, opts), "\n"), "\n") {
 715		if l == "" {
 716			b.WriteString("\n")
 717			continue
 718		}
 719		b.WriteString(indent + l + "\n")
 720	}
 721	return b.String()
 722}
 723
 724// renderSection is the heading, the rows laid out as one table with no
 725// header, each row's body indented beneath it, and the "+n more" line.
 726func (c *Ctx) renderSection(s section) string {
 727	if len(s.rows) == 0 && !s.empty {
 728		return ""
 729	}
 730	var b strings.Builder
 731	b.WriteString(c.Term.paint(sgrBold+sgrBlue, fmt.Sprintf("%s (%d)", s.title, s.n)) + "\n")
 732	tb := &table{term: c.Term, w: io.Discard}
 733	for _, r := range s.rows {
 734		tb.row(append([]cell(nil), r.cells...)...)
 735	}
 736	if len(s.rows) > 0 {
 737		tb.header = nil
 738		for i, l := range tb.lines() {
 739			b.WriteString(l + "\n")
 740			b.WriteString(c.renderBody(s.rows[i].body, s.rows[i].format, "  "))
 741		}
 742	}
 743	if rest := s.n - len(s.rows); rest > 0 && len(s.more) > 0 {
 744		b.WriteString(c.Term.paint(sgrDim, fmt.Sprintf("+%d more  %s", rest, c.cmdline(s.more))) + "\n")
 745	}
 746	return b.String()
 747}
 748```
 749
 750`renderLegend` and `cmdline` come in task 4; add stubs now so the package builds:
 751
 752```go
 753func (c *Ctx) renderLegend(as []action) string { return "" }
 754
 755func (c *Ctx) cmdline(argv []string) string { return "gitbay " + strings.Join(argv, " ") }
 756```
 757
 758Check the module path in `go.mod` and use it in the `termtext` import.
 759
 760`tb.lines()` relies on task 2's rule that a table with no numeric column has no header; a section whose rows carry `cNum` would otherwise print the header slice, which is nil here, so `tb.header = nil` before `lines()` keeps sections header-less in every case.
 761
 762- [ ] **Step 4: Run tests**
 763
 764Run: `go test ./internal/control/ -run 'TestRender'`
 765Expected: PASS. If `TestRenderPlainLayout` differs only in the field padding, the rule is: label, colon, then spaces to `wide+2` columns (`wide` = longest label + 1).
 766
 767- [ ] **Step 5: Commit**
 768
 769```bash
 770git add internal/control/screen.go internal/control/screen_test.go
 771git commit -m "control: screen model and renderer
 772
 773Ref #317"
 774```
 775
 776### Task 4: Legend, `cmdline`, and `here=`
 777
 778**Files:**
 779- Modify: `internal/control/screen.go` (replace the two stubs)
 780- Modify: `internal/control/term.go` (`Term.Here`, `ParseTerm`)
 781- Test: `internal/control/screen_test.go`, `internal/control/term_test.go`
 782
 783**Interfaces:**
 784- Produces: `Term.Here string`; `func (c *Ctx) cmdline(argv []string) string`; `func (c *Ctx) renderLegend(as []action) string`.
 785
 786- [ ] **Step 1: Write failing tests**
 787
 788In `term_test.go`, add cases to `TestParseTerm`'s table (match its existing struct shape):
 789
 790```go
 791		{"120,color,here=krz/gitbay", Term{Cols: 120, Color: true, Here: "krz/gitbay"}},
 792		{"120,here=", Term{Cols: 120}},
 793```
 794
 795In `screen_test.go`:
 796
 797```go
 798func TestCmdlineDropsHere(t *testing.T) {
 799	c := screenCtx(80, false)
 800	c.Term.Here = "krz/gitbay"
 801	if got, want := c.cmdline([]string{"mr", "diff", "krz/gitbay", "552"}), "gitbay mr diff 552"; got != want {
 802		t.Errorf("got %q, want %q", got, want)
 803	}
 804	if got, want := c.cmdline([]string{"issue", "show", "krz/hutch", "3"}), "gitbay issue show krz/hutch 3"; got != want {
 805		t.Errorf("got %q, want %q", got, want)
 806	}
 807	if got, want := c.cmdline([]string{"issue", "list", "--label", "needs review"}), "gitbay issue list --label 'needs review'"; got != want {
 808		t.Errorf("got %q, want %q", got, want)
 809	}
 810}
 811
 812func legendSample() []action {
 813	return []action{
 814		{"Unblock", []string{"mr", "rebase", "552"}},
 815		{"Review", []string{"mr", "review", "krz/gitbay", "552", "--approve"}},
 816		{"Review", []string{"mr", "comment", "krz/gitbay", "552"}},
 817		{"Read", []string{"mr", "diff", "krz/gitbay", "552"}},
 818	}
 819}
 820
 821func TestLegendColumns(t *testing.T) {
 822	c := screenCtx(120, false)
 823	c.Term.Here = "krz/gitbay"
 824	got := c.renderLegend(legendSample())
 825	want := strings.Repeat("─", 120) + "\n" +
 826		"Unblock               Review                        Read\n" +
 827		"gitbay mr rebase 552  gitbay mr review 552 --approve  gitbay mr diff 552\n" +
 828		"                      gitbay mr comment 552\n"
 829	if got != want {
 830		t.Errorf("got:\n%s\nwant:\n%s", got, want)
 831	}
 832}
 833
 834func TestLegendStacksWhenNarrow(t *testing.T) {
 835	c := screenCtx(60, false)
 836	c.Term.Here = "krz/gitbay"
 837	got := c.renderLegend(legendSample())
 838	want := strings.Repeat("─", 60) + "\n" +
 839		"Unblock\ngitbay mr rebase 552\n" +
 840		"Review\ngitbay mr review 552 --approve\ngitbay mr comment 552\n" +
 841		"Read\ngitbay mr diff 552\n"
 842	if got != want {
 843		t.Errorf("got:\n%s\nwant:\n%s", got, want)
 844	}
 845}
 846
 847func TestLegendColour(t *testing.T) {
 848	c := screenCtx(120, true)
 849	got := c.renderLegend(legendSample())
 850	if !strings.Contains(got, sgrBlue+"gitbay mr rebase 552"+sgrReset) || !strings.Contains(got, sgrBold+"Unblock"+sgrReset) {
 851		t.Errorf("legend paint: %q", got)
 852	}
 853	if stripSGR(got) != screenCtx(120, false).renderLegend(legendSample()) {
 854		t.Error("colour legend differs beyond SGR")
 855	}
 856}
 857```
 858
 859- [ ] **Step 2: Run to confirm failure**
 860
 861Run: `go test ./internal/control/ -run 'TestParseTerm|TestCmdline|TestLegend'`
 862Expected: FAIL (`Here` unknown field; stub output).
 863
 864- [ ] **Step 3: Implement**
 865
 866In `term.go`, add to `Term`:
 867
 868```go
 869	// Here is the repository the CLI inferred from the clone it runs in,
 870	// so a suggested command can leave it out.
 871	Here string
 872```
 873
 874and in `ParseTerm`'s loop, before `switch opt`:
 875
 876```go
 877		if v, ok := strings.CutPrefix(opt, "here="); ok {
 878			t.Here = v
 879			continue
 880		}
 881```
 882
 883In `screen.go`, replace the stubs:
 884
 885```go
 886// cmdline is argv as the viewer would type it: "gitbay", the words
 887// shell-quoted, and the repository the CLI inferred left out.
 888func (c *Ctx) cmdline(argv []string) string {
 889	words := []string{"gitbay"}
 890	for _, a := range argv {
 891		if c.Term.Here != "" && a == c.Term.Here {
 892			continue
 893		}
 894		words = append(words, shellWord(a))
 895	}
 896	return strings.Join(words, " ")
 897}
 898
 899// renderLegend is a rule, then the action groups in the order they first
 900// appear, each a bold name over its commands in blue. Groups sit side by
 901// side, three to a band, when the terminal is 80 wide or more and the
 902// band fits; otherwise they stack.
 903func (c *Ctx) renderLegend(as []action) string {
 904	if len(as) == 0 {
 905		return ""
 906	}
 907	t := c.Term
 908	type group struct {
 909		name string
 910		cmds []string
 911	}
 912	var groups []*group
 913	byName := map[string]*group{}
 914	for _, a := range as {
 915		g := byName[a.group]
 916		if g == nil {
 917			g = &group{name: a.group}
 918			byName[a.group] = g
 919			groups = append(groups, g)
 920		}
 921		g.cmds = append(g.cmds, c.cmdline(a.argv))
 922	}
 923	var b strings.Builder
 924	b.WriteString(t.paint(sgrDim, strings.Repeat("─", t.Cols)) + "\n")
 925	for start := 0; start < len(groups); start += 3 {
 926		band := groups[start:min(start+3, len(groups))]
 927		widths := make([]int, len(band))
 928		total, height := 0, 0
 929		for i, g := range band {
 930			widths[i] = cells(g.name)
 931			for _, cmd := range g.cmds {
 932				widths[i] = max(widths[i], cells(cmd))
 933			}
 934			total += widths[i]
 935			height = max(height, len(g.cmds))
 936		}
 937		total += 2 * (len(band) - 1)
 938		if t.Cols < 80 || total > t.Cols {
 939			for _, g := range band {
 940				b.WriteString(t.paint(sgrBold, g.name) + "\n")
 941				for _, cmd := range g.cmds {
 942					b.WriteString(t.paint(sgrBlue, cmd) + "\n")
 943				}
 944			}
 945			continue
 946		}
 947		for line := -1; line < height; line++ {
 948			var l strings.Builder
 949			for i, g := range band {
 950				s, paint := "", sgrBlue
 951				if line < 0 {
 952					s, paint = g.name, sgrBold
 953				} else if line < len(g.cmds) {
 954					s = g.cmds[line]
 955				}
 956				if i < len(band)-1 {
 957					l.WriteString(t.paint(paint, s) + strings.Repeat(" ", widths[i]-cells(s)+2))
 958				} else {
 959					l.WriteString(t.paint(paint, s))
 960				}
 961			}
 962			b.WriteString(strings.TrimRight(l.String(), " ") + "\n")
 963		}
 964	}
 965	return b.String()
 966}
 967```
 968
 969`shellWord` already exists (used by `emitPage`); if it is in `cmd/gitbay` rather than `internal/control`, find it with `grep -rn 'func shellWord' internal/` and use that one.
 970
 971- [ ] **Step 4: Run tests**
 972
 973Run: `go test ./internal/control/`
 974Expected: PASS.
 975
 976- [ ] **Step 5: Commit**
 977
 978```bash
 979git add internal/control/screen.go internal/control/term.go internal/control/*_test.go
 980git commit -m "control: action legend; here= names the inferred repository
 981
 982Ref #317"
 983```
 984
 985### Task 5: `emitView`, `emitPageView`, the next-page line
 986
 987**Files:**
 988- Modify: `internal/control/control.go` (after `emit`)
 989- Modify: `internal/control/cursor.go` (`emitPage`)
 990- Test: `internal/control/screen_test.go`, `internal/control/cursor_test.go:84`
 991
 992**Interfaces:**
 993- Consumes: `render`, `cmdline` (tasks 3–4).
 994- Produces:
 995
 996```go
 997func (c *Ctx) emitView(data any, plain func(w io.Writer), build func() screen) int
 998func (c *Ctx) emitPageView(p page, items any, next string, plain func(w io.Writer), build func() screen) int
 999func (c *Ctx) nextArgv(next string) []string
1000```
1001
1002- [ ] **Step 1: Write failing tests**
1003
1004```go
1005func TestEmitViewRoutes(t *testing.T) {
1006	built := false
1007	build := func() screen { built = true; return screen{fields: []field{{"Repo", []cell{cText("a/b")}}}} }
1008	plain := func(w io.Writer) { io.WriteString(w, "plain\n") }
1009
1010	var out bytes.Buffer
1011	c := &Ctx{Stdout: &out, Stderr: io.Discard}
1012	c.emitView(map[string]string{"k": "v"}, plain, build)
1013	if out.String() != "plain\n" || built {
1014		t.Errorf("piped: %q built=%v", out.String(), built)
1015	}
1016
1017	out.Reset()
1018	c = &Ctx{Stdout: &out, Stderr: io.Discard, JSON: true, Term: Term{Cols: 80}}
1019	c.emitView(map[string]string{"k": "v"}, plain, build)
1020	if !strings.Contains(out.String(), `"k":"v"`) || built {
1021		t.Errorf("json: %q built=%v", out.String(), built)
1022	}
1023
1024	out.Reset()
1025	c = &Ctx{Stdout: &out, Stderr: io.Discard, Term: Term{Cols: 80}}
1026	c.emitView(map[string]string{"k": "v"}, plain, build)
1027	if out.String() != "Repo:  a/b\n" || !built {
1028		t.Errorf("terminal: %q built=%v", out.String(), built)
1029	}
1030}
1031```
1032
1033Add `bytes` and `io` imports to `screen_test.go`.
1034
1035In `cursor_test.go`, change the expectation at line 84 to the new stderr line:
1036
1037```go
1038	want := "Next page  gitbay build list " + repo.Path() + " --limit 2 --cursor "
1039```
1040
1041- [ ] **Step 2: Run to confirm failure**
1042
1043Run: `go test ./internal/control/ -run 'TestEmitViewRoutes|TestCursor'`
1044Expected: FAIL (`emitView` undefined; old `more:` text).
1045
1046- [ ] **Step 3: Implement**
1047
1048In `control.go`, after `emit`:
1049
1050```go
1051// emitView is emit for a command with a terminal screen: --json and
1052// piped output are emit's, and at a terminal build's screen is drawn
1053// in place of plain.
1054func (c *Ctx) emitView(data any, plain func(w io.Writer), build func() screen) int {
1055	if c.Term.Cols == 0 || c.JSON {
1056		return c.emit(data, plain)
1057	}
1058	return c.emit(data, func(w io.Writer) { c.render(w, build()) })
1059}
1060```
1061
1062In `cursor.go`, extract the argv the old `more:` line built:
1063
1064```go
1065// nextArgv is the command that fetches the page after this one: this
1066// command's argv with its cursor replaced.
1067func (c *Ctx) nextArgv(next string) []string {
1068	argv := strings.Fields(joinPath(c.Cmd.Path))
1069	for i := 0; i < len(c.Argv); i++ {
1070		if c.Argv[i] == "--cursor" {
1071			i++
1072			continue
1073		}
1074		argv = append(argv, c.Argv[i])
1075	}
1076	return append(argv, "--cursor", next)
1077}
1078```
1079
1080and replace the terminal branch of `emitPage`'s closure (from `var again []string` to the `Fprintf`) with:
1081
1082```go
1083		fmt.Fprintln(c.Stderr, c.Term.paint(sgrBold, "Next page")+"  "+c.Term.paint(sgrBlue, c.cmdline(c.nextArgv(next))))
1084```
1085
1086Then add:
1087
1088```go
1089// emitPageView is emitPage for a list with a terminal screen.
1090func (c *Ctx) emitPageView(p page, items any, next string, plain func(w io.Writer), build func() screen) int {
1091	if c.Term.Cols == 0 || c.JSON {
1092		return c.emitPage(p, items, next, plain)
1093	}
1094	return c.emitPage(p, items, next, func(w io.Writer) { c.render(w, build()) })
1095}
1096```
1097
1098`cmdline` shell-quotes each word, so the old `shellWord` loop in `emitPage` is no longer used there; remove it if nothing else in the function uses it.
1099
1100- [ ] **Step 4: Run tests**
1101
1102Run: `go test ./internal/control/`
1103Expected: PASS.
1104
1105- [ ] **Step 5: Commit**
1106
1107```bash
1108git add internal/control/control.go internal/control/cursor.go internal/control/*_test.go
1109git commit -m "control: emitView and emitPageView; next page as a legend line
1110
1111Ref #317"
1112```
1113
1114### Task 6: The CLI sends `here=`
1115
1116**Files:**
1117- Modify: `cmd/gitbay/ssh.go` (`termValue` signature and `runSSHPaged`)
1118- Test: `cmd/gitbay/term_test.go`
1119
1120**Interfaces:**
1121- Produces: `func termValue(isTerminal bool, cols int, here string, env func(string) string) string`.
1122
1123- [ ] **Step 1: Write failing test**
1124
1125Add to `cmd/gitbay/term_test.go`:
1126
1127```go
1128func TestTermValueHere(t *testing.T) {
1129	env := func(string) string { return "" }
1130	if got, want := termValue(true, 100, "krz/gitbay", env), "100,color,here=krz/gitbay"; got != want {
1131		t.Errorf("got %q, want %q", got, want)
1132	}
1133	if got := termValue(true, 100, "", env); strings.Contains(got, "here=") {
1134		t.Errorf("here= without a repository: %q", got)
1135	}
1136	if got := termValue(false, 100, "krz/gitbay", env); got != "" {
1137		t.Errorf("piped: %q", got)
1138	}
1139}
1140```
1141
1142Update the existing `termValue(c.tty, c.cols, env(c.env))` call in `term_test.go:40` to `termValue(c.tty, c.cols, "", env(c.env))`.
1143
1144- [ ] **Step 2: Run to confirm failure**
1145
1146Run: `go test ./cmd/gitbay/ -run TestTermValue`
1147Expected: build failure (too many arguments).
1148
1149- [ ] **Step 3: Implement**
1150
1151Change `termValue` to take `here` and append it last:
1152
1153```go
1154func termValue(isTerminal bool, cols int, here string, env func(string) string) string {
1155	// ... existing body up to the links check unchanged ...
1156	if linksWanted(env) {
1157		v += ",links"
1158	}
1159	if here != "" {
1160		v += ",here=" + here
1161	}
1162	return v
1163}
1164```
1165
1166Keep the `GITBAY_TERM=basic` early return as it is: an instance that old ignores `here=` anyway, and `basic` promises only width and colour. Add one sentence to the function's comment: "here is the repository inferred from the clone, for suggested commands to leave out."
1167
1168In `runSSHPaged`, pass `t.repo`:
1169
1170```go
1171	if v := termValue(isTTY, cols, t.repo, os.Getenv); v != "" && !slices.Contains(serverArgv, "--json") {
1172```
1173
1174- [ ] **Step 4: Run tests**
1175
1176Run: `go test ./cmd/gitbay/`
1177Expected: PASS.
1178
1179- [ ] **Step 5: Commit**
1180
1181```bash
1182git add cmd/gitbay/ssh.go cmd/gitbay/term_test.go
1183git commit -m "cli: send the inferred repository as here= in --term
1184
1185Ref #317"
1186```
1187
1188### Task 7: Test helpers for migrations — `checkActions` and `pinPlain`
1189
1190**Files:**
1191- Modify: `internal/control/screen_test.go`
1192- Create: `internal/control/plain_test.go`
1193
1194**Interfaces:**
1195- Produces (test-only): `func checkActions(t *testing.T, s screen)`; `func pinPlain(t *testing.T, name, got string)`; flag `-update-plain`.
1196
1197- [ ] **Step 1: Write the helpers and their own tests**
1198
1199Append to `screen_test.go`:
1200
1201```go
1202// cliLocal are commands cmd/gitbay runs itself; the registry does not
1203// know them, but a legend may suggest them.
1204var cliLocal = map[string]bool{"mr rebase": true, "mr checkout": true, "repo clone": true}
1205
1206// errorer is the part of *testing.T checkActions uses, so its own test
1207// can pass a recorder.
1208type errorer interface {
1209	Helper()
1210	Errorf(format string, args ...any)
1211}
1212
1213type recorder struct{ failed bool }
1214
1215func (r *recorder) Helper()                {}
1216func (r *recorder) Errorf(string, ...any) { r.failed = true }
1217
1218// checkActions fails t for any legend or "more" command that the
1219// registry would not dispatch, or whose flags it would refuse.
1220func checkActions(t errorer, s screen) {
1221	t.Helper()
1222	var all [][]string
1223	for _, a := range s.actions {
1224		all = append(all, a.argv)
1225	}
1226	for _, sec := range s.sections {
1227		if len(sec.more) > 0 {
1228			all = append(all, sec.more)
1229		}
1230	}
1231	for _, argv := range all {
1232		if len(argv) >= 2 && cliLocal[argv[0]+" "+argv[1]] {
1233			continue
1234		}
1235		cmd, rest, ok := Lookup(argv)
1236		if !ok {
1237			t.Errorf("no command for %q", argv)
1238			continue
1239		}
1240		if err := checkFlags(cmd, rest); err != nil {
1241			t.Errorf("%q: %v", argv, err)
1242		}
1243	}
1244}
1245
1246func TestCheckActionsCatchesTypos(t *testing.T) {
1247	r := &recorder{}
1248	checkActions(r, screen{actions: []action{{"Read", []string{"mr", "dif", "a/b", "1"}}}})
1249	if !r.failed {
1250		t.Error("a misspelled command passed")
1251	}
1252	r = &recorder{}
1253	checkActions(r, screen{actions: []action{{"Read", []string{"mr", "diff", "a/b", "1", "--bogus"}}}})
1254	if !r.failed {
1255		t.Error("an unknown flag passed")
1256	}
1257	r = &recorder{}
1258	checkActions(r, screen{actions: []action{{"Unblock", []string{"mr", "rebase", "1"}}}})
1259	if r.failed {
1260		t.Error("a CLI-local command failed")
1261	}
1262}
1263```
1264
1265`checkFlags` reads the command's declared flags (`Command.Flags`, each with
1266`Name` and `Arg`; `Arg` empty for a switch), so no store is involved. Add it
1267to `screen_test.go`:
1268
1269```go
1270// checkFlags refuses a flag the command does not declare, and a value
1271// flag with nothing after it. Words that are not flags are positionals.
1272func checkFlags(cmd Command, rest []string) error {
1273	takes := map[string]bool{}
1274	for _, f := range cmd.Flags {
1275		takes[f.Name] = f.Arg != ""
1276	}
1277	for i := 0; i < len(rest); i++ {
1278		a := rest[i]
1279		if !strings.HasPrefix(a, "--") {
1280			continue
1281		}
1282		name, _, inline := strings.Cut(a, "=")
1283		value, ok := takes[name]
1284		if !ok {
1285			return fmt.Errorf("%s does not take %s", joinPath(cmd.Path), name)
1286		}
1287		if value && !inline {
1288			if i+1 >= len(rest) {
1289				return fmt.Errorf("%s needs a value", name)
1290			}
1291			i++
1292		}
1293	}
1294	return nil
1295}
1296```
1297
1298If a command in a legend declares no `Flags` but accepts some (an older
1299registration), add its `Flags` to the registration rather than loosening
1300the check; `TestCommandsDeclareFlags` or its equivalent may already
1301enforce this (`grep -n 'Flags' internal/control/help_test.go`).
1302
1303Create `internal/control/plain_test.go`:
1304
1305```go
1306package control
1307
1308import (
1309	"flag"
1310	"os"
1311	"path/filepath"
1312	"regexp"
1313	"testing"
1314)
1315
1316var updatePlain = flag.Bool("update-plain", false, "rewrite testdata/plain from the current output")
1317
1318var plainStamp = regexp.MustCompile(`\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?(Z| UTC)?`)
1319
1320// pinPlain compares a command's piped output with the copy taken before
1321// its terminal screen was rewritten. Timestamps are masked; the fixture
1322// clock is the store's.
1323func pinPlain(t *testing.T, name, got string) {
1324	t.Helper()
1325	got = plainStamp.ReplaceAllString(got, "<time>")
1326	path := filepath.Join("testdata", "plain", name+".txt")
1327	if *updatePlain {
1328		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
1329			t.Fatal(err)
1330		}
1331		if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
1332			t.Fatal(err)
1333		}
1334		return
1335	}
1336	want, err := os.ReadFile(path)
1337	if err != nil {
1338		t.Fatalf("%v: capture it with -update-plain before changing the command", err)
1339	}
1340	if got != string(want) {
1341		t.Errorf("piped output changed:\n--- want\n%s--- got\n%s", want, got)
1342	}
1343}
1344```
1345
1346- [ ] **Step 2: Run**
1347
1348Run: `go test ./internal/control/ -run 'TestCheckActions'`
1349Expected: PASS.
1350
1351- [ ] **Step 3: Commit**
1352
1353```bash
1354git add internal/control/screen_test.go internal/control/plain_test.go
1355git commit -m "control: test helpers for screen migrations
1356
1357Ref #317"
1358```
1359
1360### Task 8: Docs, changelog, MR
1361
1362**Files:**
1363- Modify: `.gitbay/wiki/Users.org` ("Output rules" section)
1364- Modify: `CHANGELOG.org` (unreleased section at the top; create one headed `* Unreleased` if the top entry is a released version)
1365
1366- [ ] **Step 1: Rewrite "Output rules"**
1367
1368Replace the section's terminal-output rules with: the four parts of a screen and their order; the label/heading/row rules; the colour table; the glyph table; that a table names its columns only when one holds numbers; the legend and `here=`; that piped and `--json` output are unaffected. Keep the existing piped-output and exit-code rules as they are.
1369
1370- [ ] **Step 2: Changelog entry**
1371
1372```org
1373- Terminal output is drawn as screens (#317): lists drop
1374  their header row unless a column is a number, refs are dim rather than
1375  cyan, and state shows as a leading glyph where a screen has one. The
1376  next page is a =Next page= line with the command. The CLI sends the
1377  repository it inferred as =here== in =--term=, so suggested commands
1378  leave it out; an older instance ignores it. Piped and =--json= output
1379  are unchanged.
1380```
1381
1382- [ ] **Step 3: Verify and push**
1383
1384Run: `go build ./... && go vet ./... && go test ./internal/control/ ./cmd/gitbay/`
1385Expected: all PASS.
1386
1387```bash
1388git add .gitbay/wiki/Users.org CHANGELOG.org
1389git commit -m "docs: screen output rules
1390
1391Ref #317"
1392git push -u origin cli-views-1
1393gitbay mr create --source cli-views-1 --target main --title "control: screen renderer (stage 1 of CLI views)"
1394```
1395
1396CI runs the e2e suite. `e2e/term_test.go` and any e2e test that matches a terminal header row or cyan may need its expectation updated; fix on the branch, amend, force-push.
1397
1398---
1399
1400## Stage 2a: show screens (one MR, Ref #318)
1401
1402Each migration follows the same order: pin the piped output on the
1403unchanged code, write the screen test, write the builder, switch the
1404command to `emitView`, confirm the pin still matches.
1405
1406### Task 9: `gitutil.DiffNumstat`
1407
1408**Files:**
1409- Create: `internal/gitutil/numstat.go`
1410- Test: `internal/gitutil/numstat_test.go`
1411
1412**Interfaces:**
1413- Produces:
1414
1415```go
1416type NumStat struct {
1417	Path    string
1418	Added   int // -1 for a binary file
1419	Deleted int // -1 for a binary file
1420	Status  string // "A", "M", "D", "R"
1421}
1422func DiffNumstat(dir, base, head string) ([]NumStat, error)
1423```
1424
1425- [ ] **Step 1: Write failing test**
1426
1427Use the package's existing test-repository helper (`grep -n 'func.*testRepo\|func initRepo' internal/gitutil/*_test.go`) to make a repo with a base commit, then a commit that modifies one file (+2 −1), adds one, and adds a binary file. Assert:
1428
1429```go
1430	want := []NumStat{
1431		{Path: "a.txt", Added: 2, Deleted: 1, Status: "M"},
1432		{Path: "bin.dat", Added: -1, Deleted: -1, Status: "A"},
1433		{Path: "new.txt", Added: 1, Deleted: 0, Status: "A"},
1434	}
1435```
1436
1437- [ ] **Step 2: Run to confirm failure**
1438
1439Run: `go test ./internal/gitutil/ -run TestDiffNumstat`
1440Expected: `undefined: DiffNumstat`.
1441
1442- [ ] **Step 3: Implement**
1443
1444```go
1445package gitutil
1446
1447import (
1448	"fmt"
1449	"os/exec"
1450	"strconv"
1451	"strings"
1452
1453	"gitbay.org/krz/gitbay/internal/toolpath"
1454)
1455
1456// NumStat is one file's line counts between two commits. A binary file
1457// counts -1 each way.
1458type NumStat struct {
1459	Path    string
1460	Added   int
1461	Deleted int
1462	Status  string
1463}
1464
1465// DiffNumstat lists the files changed from base to head, sorted by path,
1466// with line counts and A/M/D/R status. Renames are detected.
1467func DiffNumstat(dir, base, head string) ([]NumStat, error) {
1468	run := func(args ...string) (string, error) {
1469		cmd := exec.Command(toolpath.Look("git"), append([]string{"-C", dir}, args...)...)
1470		out, err := cmd.Output()
1471		if err != nil {
1472			return "", fmt.Errorf("git %s: %w", args[0], err)
1473		}
1474		return string(out), nil
1475	}
1476	num, err := run("diff", "--numstat", "-z", "-M", "--end-of-options", base, head)
1477	if err != nil {
1478		return nil, err
1479	}
1480	names, err := run("diff", "--name-status", "-z", "-M", "--end-of-options", base, head)
1481	if err != nil {
1482		return nil, err
1483	}
1484	status := map[string]string{}
1485	f := strings.Split(strings.TrimSuffix(names, "\x00"), "\x00")
1486	for i := 0; i < len(f); {
1487		s := f[i]
1488		if strings.HasPrefix(s, "R") || strings.HasPrefix(s, "C") {
1489			if i+2 < len(f) {
1490				status[f[i+2]] = s[:1]
1491			}
1492			i += 3
1493			continue
1494		}
1495		if i+1 < len(f) {
1496			status[f[i+1]] = s[:1]
1497		}
1498		i += 2
1499	}
1500	var out []NumStat
1501	recs := strings.Split(strings.TrimSuffix(num, "\x00"), "\x00")
1502	for i := 0; i < len(recs); i++ {
1503		parts := strings.SplitN(recs[i], "\t", 3)
1504		if len(parts) != 3 {
1505			continue
1506		}
1507		path := parts[2]
1508		if path == "" && i+2 < len(recs) { // rename: "a\td\t" then old, new
1509			path = recs[i+2]
1510			i += 2
1511		}
1512		ns := NumStat{Path: path, Added: -1, Deleted: -1, Status: status[path]}
1513		if parts[0] != "-" {
1514			ns.Added, _ = strconv.Atoi(parts[0])
1515			ns.Deleted, _ = strconv.Atoi(parts[1])
1516		}
1517		out = append(out, ns)
1518	}
1519	return out, nil
1520}
1521```
1522
1523Check the `toolpath` import path against `merge.go`'s imports and match it.
1524
1525- [ ] **Step 4: Run tests**
1526
1527Run: `go test ./internal/gitutil/ -run TestDiffNumstat`
1528Expected: PASS.
1529
1530- [ ] **Step 5: Commit**
1531
1532```bash
1533git add internal/gitutil/numstat.go internal/gitutil/numstat_test.go
1534git commit -m "gitutil: DiffNumstat
1535
1536Ref #318"
1537```
1538
1539### Task 10: `mr show` screen
1540
1541**Files:**
1542- Modify: `internal/control/mr.go` (`runMRShow` at line 739; new `mrShowScreen`)
1543- Test: `internal/control/mrscreen_test.go` (new), `internal/control/testdata/plain/mr-show.txt` (new)
1544
1545**Interfaces:**
1546- Consumes: `screen` types, `emitView`, `cGlyph`, `cYou`, `cMeta`, `checksMark` (`marks.go:15`), `gitutil.DiffNumstat`.
1547- Produces: `func mrShowScreen(c *Ctx, repo store.Repo, d MRShow, files []gitutil.NumStat) screen`.
1548
1549- [ ] **Step 1: Pin the piped output on unchanged code**
1550
1551`internal/control/mrscreen_test.go`:
1552
1553```go
1554package control
1555
1556import (
1557	"testing"
1558
1559	"gitbay.org/krz/gitbay/internal/gitutil"
1560	"gitbay.org/krz/gitbay/internal/protocol"
1561)
1562
1563func TestMRShowPlainPinned(t *testing.T) {
1564	st, repo, owner := twoMRTestRepo(t)
1565	c, out, errOut := mrTestCtx(st, owner)
1566	if code := Dispatch(c, []string{"mr", "show", repo.Path(), "1"}); code != protocol.ExitOK {
1567		t.Fatalf("exit %d: %s", code, errOut)
1568	}
1569	pinPlain(t, "mr-show", out.String())
1570}
1571```
1572
1573Run: `go test ./internal/control/ -run TestMRShowPlainPinned -update-plain`
1574then `go test ./internal/control/ -run TestMRShowPlainPinned`
1575Expected: PASS both times; `testdata/plain/mr-show.txt` exists.
1576
1577- [ ] **Step 2: Write the failing screen tests**
1578
1579```go
1580func mrShowFixture() MRShow {
1581	d := MRShow{mrOut: mrOut{Number: 552, Title: "wire $PAGER through long views", State: "open",
1582		Author: "cmc", Source: "cli-pager", TargetRef: "main", Body: "Pages long views.", BodyFormat: "markdown",
1583		CreatedAt: "2026-10-01T11:40:00Z"}}
1584	d.Commits = []CommitOut{{"8f3a1c2aaaaaaaa", "cli: page long output"}, {"2b77e90bbbbbbbb", "control: mark views"}}
1585	d.Checks = []CheckOut{{Context: "test", State: "pending"}, {Context: "build", State: "success"}}
1586	d.Gates = &GatesOut{ApprovalsRequired: 1, FastForward: false, Unmet: []string{"needs 1 approval", "behind main"}}
1587	return d
1588}
1589
1590func actionArgvs(s screen, group string) [][]string {
1591	var out [][]string
1592	for _, a := range s.actions {
1593		if a.group == group {
1594			out = append(out, a.argv)
1595		}
1596	}
1597	return out
1598}
1599
1600func TestMRShowScreenBehind(t *testing.T) {
1601	c := screenCtx(100, false)
1602	repo := store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"}
1603	s := mrShowScreen(c, repo, mrShowFixture(), []gitutil.NumStat{{Path: "cmd/gitbay/ssh.go", Added: 41, Deleted: 6, Status: "M"}})
1604	if got := actionArgvs(s, "Unblock"); len(got) != 1 || got[0][1] != "rebase" {
1605		t.Errorf("Unblock = %q, want mr rebase", got)
1606	}
1607	if got := actionArgvs(s, "Merge"); len(got) != 0 {
1608		t.Errorf("Merge offered with unmet gates: %q", got)
1609	}
1610	titles := map[string]int{}
1611	for _, sec := range s.sections {
1612		titles[sec.title] = sec.n
1613	}
1614	if titles["Commits"] != 2 || titles["Files"] != 1 {
1615		t.Errorf("sections = %v", titles)
1616	}
1617	checkActions(t, s)
1618}
1619
1620func TestMRShowScreenReady(t *testing.T) {
1621	c := screenCtx(100, false)
1622	d := mrShowFixture()
1623	d.Gates = &GatesOut{FastForward: true}
1624	repo := store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"}
1625	s := mrShowScreen(c, repo, d, nil)
1626	if len(actionArgvs(s, "Unblock")) != 0 {
1627		t.Error("Unblock offered when nothing blocks")
1628	}
1629	if got := actionArgvs(s, "Merge"); len(got) != 1 || got[0][1] != "merge" {
1630		t.Errorf("Merge = %q", got)
1631	}
1632	checkActions(t, s)
1633}
1634
1635func TestMRShowScreenClosedOffersNoWrites(t *testing.T) {
1636	c := screenCtx(100, false)
1637	d := mrShowFixture()
1638	d.State, d.Gates = "merged", nil
1639	s := mrShowScreen(c, store.Repo{OwnerName: "krz", Name: "gitbay"}, d, nil)
1640	for _, a := range s.actions {
1641		if a.group != "Read" {
1642			t.Errorf("merged MR offers %q", a.argv)
1643		}
1644	}
1645}
1646```
1647
1648Add `store` to the imports.
1649
1650Run: `go test ./internal/control/ -run TestMRShowScreen`
1651Expected: `undefined: mrShowScreen`.
1652
1653- [ ] **Step 3: Implement `mrShowScreen`**
1654
1655Add to `mr.go` after `runMRShow`:
1656
1657```go
1658// mrShowScreen is mr show at a terminal: where the merge request stands,
1659// its description, its commits, files and discussion, and what can be
1660// done about it now.
1661func mrShowScreen(c *Ctx, repo store.Repo, d MRShow, files []gitutil.NumStat) screen {
1662	n := strconv.FormatInt(d.Number, 10)
1663	path := repo.Path()
1664	state := d.State
1665	if d.Draft {
1666		state = "draft"
1667	}
1668	s := screen{body: d.Body, format: d.BodyFormat}
1669
1670	s.fields = append(s.fields,
1671		field{"Merge", []cell{cLink("!"+n, c.siteURL(path, "mrs", n)), cText(d.Title)}},
1672		field{"State", []cell{cState(state), cMeta(d.Source+" → "+d.TargetRef, d.Author, relAge(d.CreatedAt, termNow()))}},
1673	)
1674	if len(d.Checks) > 0 {
1675		var sts []store.CommitStatus
1676		for _, x := range d.Checks {
1677			sts = append(sts, store.CommitStatus{Context: x.Context, State: x.State})
1678		}
1679		m := checksMark(sts)
1680		s.fields = append(s.fields, field{"Checks", []cell{m}})
1681	}
1682	open := d.State == "open" || d.State == "source_gone"
1683	behind := false
1684	if g := d.Gates; g != nil && open {
1685		var parts []string
1686		for _, u := range g.Unmet {
1687			parts = append(parts, "✗ "+u)
1688		}
1689		behind = !g.FastForward
1690		if len(parts) == 0 {
1691			s.fields = append(s.fields, field{"Gates", []cell{cMark("✓ ready to merge", sgrGreen)}})
1692		} else {
1693			s.fields = append(s.fields, field{"Gates", []cell{cMark(strings.Join(parts, " · "), sgrRed)}})
1694		}
1695	}
1696	if len(d.ReviewRequests) > 0 {
1697		v := cText("requested of " + strings.Join(d.ReviewRequests, ", "))
1698		if slices.Contains(d.ReviewRequests, c.User.Username) {
1699			v.sgr = sgrYellow
1700		}
1701		s.fields = append(s.fields, field{"Review", []cell{v}})
1702	}
1703	if d.Milestone != "" {
1704		s.fields = append(s.fields, field{"Milestone", []cell{cText(d.Milestone)}})
1705	}
1706	if len(d.Labels) > 0 {
1707		s.fields = append(s.fields, field{"Labels", []cell{cText(strings.Join(d.Labels, ", "))}})
1708	}
1709	if !c.Term.Links {
1710		s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "mrs", n))}})
1711	}
1712
1713	commits := section{title: "Commits", n: len(d.Commits)}
1714	for _, cm := range d.Commits {
1715		commits.rows = append(commits.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject)))
1716	}
1717	add, del := 0, 0
1718	filesSec := section{n: len(files)}
1719	for _, f := range files {
1720		counts := "binary"
1721		if f.Added >= 0 {
1722			add, del = add+f.Added, del+f.Deleted
1723			counts = fmt.Sprintf("+%d −%d", f.Added, f.Deleted)
1724		}
1725		filesSec.rows = append(filesSec.rows, rowOf(cRef(f.Status), cFlex(f.Path), cMeta(counts)))
1726	}
1727	filesSec.title = fmt.Sprintf("Files  +%d −%d", add, del)
1728	if len(files) == 0 {
1729		filesSec.title = "Files"
1730	}
1731	disc := section{title: "Discussion", empty: true}
1732	for _, cm := range d.Comments {
1733		if cm.Kind == "system" {
1734			continue
1735		}
1736		disc.n++
1737		disc.rows = append(disc.rows, row{cells: []cell{cText(cm.Author), cMeta(relAge(cm.CreatedAt, termNow()))}, body: cm.Body, format: cm.BodyFormat})
1738	}
1739	s.sections = []section{commits, filesSec, disc}
1740
1741	if open {
1742		if behind {
1743			s.actions = append(s.actions, action{"Unblock", []string{"mr", "rebase", n}})
1744		}
1745		s.actions = append(s.actions,
1746			action{"Review", []string{"mr", "review", path, n, "--approve"}},
1747			action{"Review", []string{"mr", "comment", path, n}},
1748		)
1749		if d.Gates != nil && len(d.Gates.Unmet) == 0 {
1750			s.actions = append(s.actions, action{"Merge", []string{"mr", "merge", path, n}})
1751		}
1752	}
1753	s.actions = append(s.actions, action{"Read", []string{"mr", "diff", path, n}})
1754	return s
1755}
1756```
1757
1758Notes for the implementer:
1759- `mr rebase` is CLI-local and infers the repository from the clone, so it takes only the number.
1760- The "Files" title carries the totals as the spec's `Files (n) +a −d`; `renderSection` prints `title (n)`, so title `Files  +86 −12` with `n: 4` renders as `Files  +86 −12 (4)`. If that reads badly in the golden, add an optional `note string` to `section` drawn dim after the count, and use it here instead. Decide when you see the render; keep the test on `n`.
1761
1762In `runMRShow`, compute files only at a terminal and switch to `emitView`:
1763
1764```go
1765	var files []gitutil.NumStat
1766	if c.Term.Cols > 0 && !c.JSON && base != "" {
1767		files, _ = gitutil.DiffNumstat(dir, base, mrHeadRef(mr.Number))
1768	}
1769	return c.emitView(d, func(w io.Writer) {
1770		// existing plain closure, unchanged
1771	}, func() screen { return mrShowScreen(c, repo, d, files) })
1772```
1773
1774- [ ] **Step 4: Run tests**
1775
1776Run: `go test ./internal/control/ -run 'TestMRShow'`
1777Expected: all PASS, including `TestMRShowPlainPinned` against the pin taken in step 1.
1778
1779- [ ] **Step 5: Look at it**
1780
1781Build and run against a scratch instance or production read-only:
1782
1783```bash
1784go build -o /tmp/gitbay ./cmd/gitbay && /tmp/gitbay mr show krz/gitbay 551
1785```
1786
1787The server renders, so this shows the new screen only after deploy; until then, render from a test: add a temporary `t.Log(renderString(c, s))` in `TestMRShowScreenBehind`, run with `-v`, read it, remove the log.
1788
1789- [ ] **Step 6: Commit**
1790
1791```bash
1792git add internal/control/mr.go internal/control/mrscreen_test.go internal/control/testdata/plain/mr-show.txt
1793git commit -m "control: mr show as a screen
1794
1795Ref #318"
1796```
1797
1798### Task 11: `issue show` screen
1799
1800**Files:**
1801- Modify: `internal/control/issue.go` (`runIssueShow` at line 381; new `issueShowScreen`)
1802- Test: `internal/control/issuescreen_test.go` (new), `testdata/plain/issue-show.txt`
1803
1804**Interfaces:**
1805- Produces: `func issueShowScreen(c *Ctx, repo store.Repo, d IssueShow, canWrite bool) screen`.
1806
1807- [ ] **Step 1: Pin piped output**
1808
1809```go
1810func TestIssueShowPlainPinned(t *testing.T) {
1811	st, repo, uid := newQueueTestRepo(t)
1812	owner := store.User{ID: uid, Username: "alice"}
1813	id, err := st.CreateIssue(repo.ID, uid, "Android app", "An app.", "markdown")
1814	if err != nil {
1815		t.Fatal(err)
1816	}
1817	if err := st.AddIssueComment(id, uid, "Started.", "markdown"); err != nil {
1818		t.Fatal(err)
1819	}
1820	c, out, errOut := mrTestCtx(st, owner)
1821	if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK {
1822		t.Fatalf("exit %d: %s", code, errOut)
1823	}
1824	pinPlain(t, "issue-show", out.String())
1825}
1826```
1827
1828Run with `-update-plain`, then without. Expected: PASS.
1829
1830- [ ] **Step 2: Failing screen tests**
1831
1832```go
1833func issueFixture() IssueShow {
1834	return IssueShow{issueOut: issueOut{Number: 12, Title: "Android app", State: "open", Author: "cmc",
1835		Labels: []string{"mobile"}, Assignees: []string{"alice"}, Body: "An app.", BodyFormat: "markdown",
1836		CreatedAt: "2026-08-21T10:00:00Z"},
1837		Comments: []commentOut{{ID: 1, Author: "cmc", Body: "Started.", BodyFormat: "markdown", CreatedAt: "2026-09-01T10:00:00Z", Kind: "comment"},
1838			{ID: 2, Author: "cmc", Body: "labelled mobile", CreatedAt: "2026-09-01T10:00:00Z", Kind: "system"}}}
1839}
1840
1841func TestIssueShowScreen(t *testing.T) {
1842	c := screenCtx(100, false)
1843	c.User = store.User{Username: "alice"}
1844	repo := store.Repo{OwnerName: "krz", Name: "gitbay"}
1845	s := issueShowScreen(c, repo, issueFixture(), true)
1846	if len(s.sections) != 1 || s.sections[0].title != "Discussion" || s.sections[0].n != 1 {
1847		t.Errorf("sections = %+v", s.sections)
1848	}
1849	var verbs []string
1850	for _, a := range s.actions {
1851		verbs = append(verbs, a.argv[1])
1852	}
1853	if !slices.Equal(verbs, []string{"comment", "assign", "label", "close"}) {
1854		t.Errorf("actions = %q", verbs)
1855	}
1856	checkActions(t, s)
1857}
1858
1859func TestIssueShowScreenReaderOnlyComments(t *testing.T) {
1860	c := screenCtx(100, false)
1861	c.User = store.User{Username: "bob"}
1862	s := issueShowScreen(c, store.Repo{OwnerName: "krz", Name: "gitbay"}, issueFixture(), false)
1863	if len(s.actions) != 1 || s.actions[0].argv[1] != "comment" {
1864		t.Errorf("reader actions = %+v", s.actions)
1865	}
1866}
1867```
1868
1869Expected on run: `undefined: issueShowScreen`.
1870
1871- [ ] **Step 3: Implement**
1872
1873```go
1874// issueShowScreen is issue show at a terminal. canWrite is write access
1875// or authorship: what lets the viewer close, assign and label.
1876func issueShowScreen(c *Ctx, repo store.Repo, d IssueShow, canWrite bool) screen {
1877	n := strconv.FormatInt(d.Number, 10)
1878	path := repo.Path()
1879	s := screen{body: d.Body, format: d.BodyFormat}
1880	s.fields = append(s.fields,
1881		field{"Issue", []cell{cLink("#"+n, c.siteURL(path, "issues", n)), cText(d.Title)}},
1882		field{"State", []cell{cState(d.State), cMeta(d.Author, relAge(d.CreatedAt, termNow()))}},
1883	)
1884	if len(d.Labels) > 0 {
1885		s.fields = append(s.fields, field{"Labels", []cell{cText(strings.Join(d.Labels, ", "))}})
1886	}
1887	if len(d.Assignees) > 0 {
1888		v := cText(strings.Join(d.Assignees, ", "))
1889		if slices.Contains(d.Assignees, c.User.Username) {
1890			v.sgr = sgrYellow
1891		}
1892		s.fields = append(s.fields, field{"Assignee", []cell{v}})
1893	}
1894	if d.Milestone != "" {
1895		s.fields = append(s.fields, field{"Milestone", []cell{cText(d.Milestone)}})
1896	}
1897	if !c.Term.Links {
1898		s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "issues", n))}})
1899	}
1900	disc := section{title: "Discussion", empty: true}
1901	for _, cm := range d.Comments {
1902		if cm.Kind == "system" {
1903			continue
1904		}
1905		disc.n++
1906		disc.rows = append(disc.rows, row{cells: []cell{cText(cm.Author), cMeta(relAge(cm.CreatedAt, termNow()))}, body: cm.Body, format: cm.BodyFormat})
1907	}
1908	s.sections = []section{disc}
1909
1910	s.actions = append(s.actions, action{"Discuss", []string{"issue", "comment", path, n}})
1911	if canWrite {
1912		s.actions = append(s.actions,
1913			action{"Triage", []string{"issue", "assign", path, n, "--add", c.User.Username}},
1914			action{"Triage", []string{"issue", "label", path, n, "--add", "<label>"}},
1915		)
1916		verb := "close"
1917		if d.State == "closed" {
1918			verb = "reopen"
1919		}
1920		s.actions = append(s.actions, action{"State", []string{"issue", verb, path, n}})
1921	}
1922	return s
1923}
1924```
1925
1926Check `issue assign` and `issue label` flags with `ssh git@gitbay.org help issue assign` and `help issue label`; adjust the argv to the real flags. A literal `<label>` placeholder in a suggested command is acceptable in the legend (it is what the user fills in) but `checkActions` will parse it as a value; that is fine.
1927
1928In `runIssueShow`, compute `canWrite` with the same rule `setIssueState` uses (author, or `policy.CanWrite` on the access role) only at a terminal, and switch to `emitView(d, plain, func() screen { return issueShowScreen(c, repo, d, canWrite) })`.
1929
1930- [ ] **Step 4: Run tests**
1931
1932Run: `go test ./internal/control/ -run 'TestIssueShow'`
1933Expected: PASS.
1934
1935- [ ] **Step 5: Commit**
1936
1937```bash
1938git add internal/control/issue.go internal/control/issuescreen_test.go internal/control/testdata/plain/issue-show.txt
1939git commit -m "control: issue show as a screen
1940
1941Ref #318"
1942```
1943
1944### Task 12: `repo show` screen
1945
1946**Files:**
1947- Modify: `internal/control/repo.go` (`runRepoShow` at line 376, `repoGlance`, new `repoShowScreen`)
1948- Test: `internal/control/reposcreen_test.go` (new), `testdata/plain/repo-show.txt`
1949
1950**Interfaces:**
1951- Produces: `func repoShowScreen(c *Ctx, d repoShowOut, g repoGlance, mrs []store.MR, issues []store.Issue, commits []CommitOut) screen`. Hoist the local `type out struct` in `runRepoShow` to package level as `repoShowOut` (and `mirrorOut` as `repoMirrorOut`) so the builder can take it; the JSON tags do not change.
1952
1953- [ ] **Step 1: Pin piped output**
1954
1955```go
1956func TestRepoShowPlainPinned(t *testing.T) {
1957	st, repo, uid := newQueueTestRepo(t)
1958	c, out, errOut := mrTestCtx(st, store.User{ID: uid, Username: "alice"})
1959	if code := Dispatch(c, []string{"repo", "show", repo.Path()}); code != protocol.ExitOK {
1960		t.Fatalf("exit %d: %s", code, errOut)
1961	}
1962	pinPlain(t, "repo-show", out.String())
1963}
1964```
1965
1966Run with `-update-plain`, then without. Expected: PASS. Then hoist the types; run again; still PASS.
1967
1968- [ ] **Step 2: Failing screen test**
1969
1970```go
1971func TestRepoShowScreen(t *testing.T) {
1972	c := screenCtx(100, false)
1973	d := repoShowOut{Path: "krz/gitbay", Description: "A CLI-first git forge.", Visibility: "public", DefaultBranch: "main",
1974		Topics: []string{"cli", "forge"}}
1975	g := repoGlance{clone: "ssh://git@gitbay.org/krz/gitbay.git", release: "v1.41.0, 2h ago", checks: "✓ 2/2 on main"}
1976	mrs := []store.MR{{Number: 552, Title: "wire $PAGER", UpdatedAt: "2026-10-01T11:40:00Z"}}
1977	s := repoShowScreen(c, d, g, mrs, nil, []CommitOut{{"a136534ff9", "changelog: v1.41.0"}})
1978	got := map[string]int{}
1979	for _, sec := range s.sections {
1980		got[sec.title] = sec.n
1981	}
1982	if got["Open merge requests"] != 1 || got["Recent commits"] != 1 {
1983		t.Errorf("sections = %v", got)
1984	}
1985	if _, ok := got["Open issues"]; ok && got["Open issues"] == 0 && len(s.sections) == 3 {
1986		// an empty section with no rows is not drawn; it may be present in the model
1987	}
1988	checkActions(t, s)
1989}
1990```
1991
1992Expected on run: `undefined: repoShowScreen`.
1993
1994- [ ] **Step 3: Implement**
1995
1996`repoAtAGlance` keeps its fields; add the counts as ints (`issues, mrs int`) beside the strings so sections can carry totals, and fill them from `c.Store.OpenCounts`.
1997
1998```go
1999// repoShowScreen is repo show at a terminal: how to clone it, where the
2000// default branch stands, what is open, and the latest commits.
2001func repoShowScreen(c *Ctx, d repoShowOut, g repoGlance, mrs []store.MR, issues []store.Issue, commits []CommitOut) screen {
2002	s := screen{body: d.Description, format: "text"}
2003	name := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility)}
2004	if d.Archived {
2005		name = append(name, cState("archived"))
2006	}
2007	s.fields = append(s.fields, field{"Repo", name}, field{"Clone", []cell{cText(g.clone)}})
2008	head := []cell{cText(d.DefaultBranch)}
2009	if g.checks != "" {
2010		head = append(head, cText(strings.TrimSuffix(g.checks, " on "+d.DefaultBranch)))
2011	}
2012	s.fields = append(s.fields, field{"Head", head})
2013	if g.release != "" {
2014		s.fields = append(s.fields, field{"Release", []cell{cText(g.release)}})
2015	}
2016	if len(d.Topics) > 0 {
2017		s.fields = append(s.fields, field{"Topics", []cell{cText(strings.Join(d.Topics, ", "))}})
2018	}
2019	if d.ForkOf != "" {
2020		s.fields = append(s.fields, field{"Fork of", []cell{cRef(d.ForkOf)}})
2021	}
2022	for _, m := range d.Mirrors {
2023		if m.LastError != "" {
2024			s.fields = append(s.fields, field{"Mirror", []cell{cGlyph("failed"), cText(m.Direction + " " + m.URL + ": " + m.LastError)}})
2025		}
2026	}
2027	if !c.Term.Links {
2028		s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(d.Path))}})
2029	}
2030
2031	ms := section{title: "Open merge requests", n: g.mrsN, more: []string{"mr", "list", d.Path}}
2032	for _, m := range mrs {
2033		ms.rows = append(ms.rows, rowOf(cRef(fmt.Sprintf("!%d", m.Number)), cFlex(m.Title), cAge(m.UpdatedAt)))
2034	}
2035	is := section{title: "Open issues", n: g.issuesN, more: []string{"issue", "list", d.Path}}
2036	for _, i := range issues {
2037		is.rows = append(is.rows, rowOf(cRef(fmt.Sprintf("#%d", i.Number)), cFlex(i.Title), cAge(i.UpdatedAt)))
2038	}
2039	cs := section{title: "Recent commits", n: len(commits), more: []string{"repo", "log", d.Path}}
2040	for _, cm := range commits {
2041		cs.rows = append(cs.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject)))
2042	}
2043	s.sections = []section{ms, is, cs}
2044	s.actions = []action{
2045		{"Contribute", []string{"mr", "create", d.Path}},
2046		{"Contribute", []string{"issue", "create", d.Path}},
2047		{"Read", []string{"repo", "log", d.Path}},
2048	}
2049	return s
2050}
2051```
2052
2053`Recent commits` sets `more`, but `n == len(rows)` so no "+n more" line prints; that is intended. In `runRepoShow`, at a terminal only, read up to 5 open MRs (`c.Store.QueryMRs` or the function `runMRList` uses, with `State: "open", Limit: 5`), up to 5 open issues (`c.Store.QueryIssues(repo.ID, store.IssueFilter{State: "open", Limit: 5})`), and 5 commits (the helper `repo log` uses; `grep -n 'func runRepoLog' internal/control/*.go` and reuse what it calls). Then `emitView(d, plain, func() screen { return repoShowScreen(c, d, glance, mrs, issues, commits) })`. The plain closure keeps only its `else` branch: delete the `c.Term.Cols > 0` branch inside it, since a terminal no longer reaches it.
2054
2055- [ ] **Step 4: Run tests**
2056
2057Run: `go test ./internal/control/ -run 'TestRepoShow'`
2058Expected: PASS, pin unchanged.
2059
2060- [ ] **Step 5: Commit**
2061
2062```bash
2063git add internal/control/repo.go internal/control/reposcreen_test.go internal/control/testdata/plain/repo-show.txt
2064git commit -m "control: repo show as a screen
2065
2066Ref #318"
2067```
2068
2069### Task 13: `build show` screen
2070
2071**Files:**
2072- Modify: `internal/control/build.go` (`runBuildShow` at line 240, new `buildShowScreen`)
2073- Test: `internal/control/buildscreen_test.go` (new), `testdata/plain/build-show.txt`
2074
2075**Interfaces:**
2076- Produces: `func buildShowScreen(c *Ctx, repo store.Repo, d BuildOut, mr *store.MR) screen`.
2077
2078- [ ] **Step 1: Pin piped output**
2079
2080```go
2081func TestBuildShowPlainPinned(t *testing.T) {
2082	st, repo, uid := newQueueTestRepo(t)
2083	id, err := st.CreateBuild(repo.ID, "test", "a136534ff9a136534ff9a136534ff9a136534ff9", "main", `["go vet ./...","go test ./..."]`, "", "", true)
2084	if err != nil {
2085		t.Fatal(err)
2086	}
2087	if err := st.FinishBuild(id, "success"); err != nil {
2088		t.Fatal(err)
2089	}
2090	c, out, errOut := mrTestCtx(st, store.User{ID: uid, Username: "alice"})
2091	if code := Dispatch(c, []string{"build", "show", repo.Path(), "1"}); code != protocol.ExitOK {
2092		t.Fatalf("exit %d: %s", code, errOut)
2093	}
2094	pinPlain(t, "build-show", out.String())
2095}
2096```
2097
2098If the build number is not 1, read it back with the store's build-by-id accessor and pass that number. Run with `-update-plain`, then without.
2099
2100- [ ] **Step 2: Failing screen test**
2101
2102```go
2103func TestBuildShowScreenFailed(t *testing.T) {
2104	c := screenCtx(100, false)
2105	d := BuildOut{Number: 83, Job: "daily-pull", Status: "failure", SHA: "355b04066b", Ref: "main",
2106		Steps: []string{"setup", "pull", "publish"}, FailedStep: 2, FailedReason: "exit 1", DurationS: 74}
2107	s := buildShowScreen(c, store.Repo{OwnerName: "krz", Name: "omaha-metro-blotter"}, d, nil)
2108	steps := s.sections[0]
2109	if steps.title != "Steps" || steps.n != 3 {
2110		t.Fatalf("steps = %+v", steps)
2111	}
2112	if g := steps.rows[1].cells[0]; g.s != "✗" {
2113		t.Errorf("failed step glyph = %q", g.s)
2114	}
2115	if g := steps.rows[2].cells[0]; g.s != "" {
2116		t.Errorf("step after the failure = %q, want no glyph", g.s)
2117	}
2118	if len(s.actions) != 1 || s.actions[0].argv[1] != "log" {
2119		t.Errorf("actions = %+v", s.actions)
2120	}
2121	checkActions(t, s)
2122}
2123```
2124
2125Expected on run: `undefined: buildShowScreen`.
2126
2127- [ ] **Step 3: Implement**
2128
2129```go
2130// buildShowScreen is build show at a terminal: the build's outcome, what
2131// it ran on, each step's outcome, and the command for its log.
2132func buildShowScreen(c *Ctx, repo store.Repo, d BuildOut, mr *store.MR) screen {
2133	n := strconv.FormatInt(d.Number, 10)
2134	path := repo.Path()
2135	var s screen
2136	s.fields = append(s.fields,
2137		field{"Build", []cell{cLink(n, c.siteURL(path, "builds", n)), cText(d.Job)}},
2138		field{"State", []cell{cGlyph(d.Status), cState(d.Status)}},
2139		field{"Commit", []cell{cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Subject)}},
2140		field{"Ref", []cell{cText(d.Ref)}},
2141	)
2142	if mr != nil {
2143		s.fields = append(s.fields, field{"MR", []cell{cRef(fmt.Sprintf("!%d", mr.Number)), cText(mr.Title)}})
2144	}
2145	if d.DurationS > 0 {
2146		s.fields = append(s.fields, field{"Duration", []cell{cText(c.Term.dur(d.DurationS))}})
2147	}
2148	if !c.Term.Links {
2149		s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "builds", n))}})
2150	}
2151	steps := section{title: "Steps", n: len(d.Steps)}
2152	for i, step := range d.Steps {
2153		line, _, _ := strings.Cut(step, "\n")
2154		state := stepState(d.Status, d.FailedStep, i+1)
2155		meta := ""
2156		if i+1 == d.FailedStep {
2157			meta = d.FailedReason
2158		}
2159		steps.rows = append(steps.rows, rowOf(cGlyph(state), cFlex(line), cMeta(meta)))
2160	}
2161	s.sections = []section{steps}
2162	s.actions = []action{{"Read", []string{"build", "log", path, n}}}
2163	return s
2164}
2165```
2166
2167Check what `stepState` returns for steps after a failure (the comment says they "never ran"); if it returns a word `glyph` maps to `○` ("skipped"), change the test's third assertion to `○`. In `runBuildShow`, move the `OpenMRBySource` lookup out of the plain closure into a terminal-only block before `emitView`, and drop the plain closure's `c.Term.Cols > 0` code (the `mr`/`logCmd` fields and the steps table), which only a terminal reached.
2168
2169- [ ] **Step 4: Run tests**
2170
2171Run: `go test ./internal/control/ -run 'TestBuildShow'`
2172Expected: PASS, pin unchanged.
2173
2174- [ ] **Step 5: Commit, verify, MR**
2175
2176```bash
2177git add internal/control/build.go internal/control/buildscreen_test.go internal/control/testdata/plain/build-show.txt
2178git commit -m "control: build show as a screen
2179
2180Ref #318"
2181go build ./... && go vet ./... && go test ./internal/control/ ./internal/gitutil/
2182git push -u origin cli-views-2a
2183gitbay mr create --source cli-views-2a --target main --title "control: show screens (stage 2a of CLI views)"
2184```
2185
2186Add a CHANGELOG line under the unreleased section naming the four screens before pushing.
2187
2188---
2189
2190## Stage 2b: lists and dashboard (one MR, Ref #318)
2191
2192### Task 14: `mr list`, `issue list`, `build list` screens
2193
2194**Files:**
2195- Modify: `internal/control/mr.go` (`mrListTerm` at line 711 and its caller), `issue.go` (`runIssueList` terminal branch), `build.go` (`runBuildList`)
2196- Test: `internal/control/listscreen_test.go` (new), `testdata/plain/{mr,issue,build}-list.txt`
2197
2198**Interfaces:**
2199- Produces:
2200
2201```go
2202func mrListScreen(c *Ctx, repo store.Repo, state string, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) screen
2203func issueListScreen(c *Ctx, repo store.Repo, state string, issues []store.Issue, ds []issueOut, comments map[int64]int, labels, assignees map[int64][]string) screen
2204func buildListScreen(c *Ctx, repo store.Repo, ds []BuildOut) screen
2205```
2206
2207- [ ] **Step 1: Pin piped output for all three**
2208
2209```go
2210func TestListsPlainPinned(t *testing.T) {
2211	st, repo, owner := twoMRTestRepo(t)
2212	if _, err := st.CreateIssue(repo.ID, owner.ID, "Android app", "", "markdown"); err != nil {
2213		t.Fatal(err)
2214	}
2215	if _, err := st.CreateBuild(repo.ID, "test", "abc111", "main", `["go test ./..."]`, "", "", true); err != nil {
2216		t.Fatal(err)
2217	}
2218	for name, argv := range map[string][]string{
2219		"mr-list":    {"mr", "list", repo.Path()},
2220		"issue-list": {"issue", "list", repo.Path()},
2221		"build-list": {"build", "list", repo.Path()},
2222	} {
2223		c, out, errOut := mrTestCtx(st, owner)
2224		if code := Dispatch(c, argv); code != protocol.ExitOK {
2225			t.Fatalf("%s: exit %d: %s", name, code, errOut)
2226		}
2227		pinPlain(t, name, out.String())
2228	}
2229}
2230```
2231
2232Run with `-update-plain`, then without.
2233
2234- [ ] **Step 2: Failing screen tests**
2235
2236```go
2237func TestMRListScreen(t *testing.T) {
2238	c := screenCtx(100, false)
2239	c.User = store.User{Username: "alice"}
2240	repo := store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"}
2241	mrs := []store.MR{{ID: 1, Number: 552, UpdatedAt: "2026-10-01T11:40:00Z"}}
2242	ds := []mrOut{{Number: 552, Title: "wire $PAGER", State: "open", Source: "cli-pager", TargetRef: "main"}}
2243	checks := map[int64]cell{1: cMark("✓ 2/2", sgrGreen)}
2244	review := map[int64]cell{1: cMark("review requested", sgrYellow)}
2245	s := mrListScreen(c, repo, "open", mrs, ds, checks, review)
2246	if len(s.sections) != 1 || s.sections[0].title != "Open merge requests" || s.sections[0].n != 1 {
2247		t.Fatalf("sections = %+v", s.sections)
2248	}
2249	if g := s.sections[0].rows[0].cells[1]; g.s != "●" {
2250		t.Errorf("review requested of the viewer should lead with ●, got %q", g.s)
2251	}
2252	var verbs []string
2253	for _, a := range s.actions {
2254		verbs = append(verbs, strings.Join(a.argv[:2], " "))
2255	}
2256	if !slices.Equal(verbs, []string{"mr create", "mr list"}) {
2257		t.Errorf("actions = %q", verbs)
2258	}
2259	checkActions(t, s)
2260}
2261```
2262
2263Write the equivalent `TestIssueListScreen` (assigned to the viewer leads with `●`; title `Open issues`; actions `issue create`, `issue list --state closed`) and `TestBuildListScreen` (glyph from status; title `Builds`; no state filter action).
2264
2265Expected on run: undefined builders.
2266
2267- [ ] **Step 3: Implement**
2268
2269```go
2270// mrListScreen is mr list at a terminal: one section of merge requests,
2271// each led by ● when it waits on the viewer or its checks' mark, then
2272// title, branch, review and age.
2273func mrListScreen(c *Ctx, repo store.Repo, state string, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) screen {
2274	title := map[string]string{"open": "Open merge requests", "closed": "Closed merge requests", "merged": "Merged merge requests", "all": "Merge requests"}[state]
2275	sec := section{title: title, n: len(ds)}
2276	for i, d := range ds {
2277		m := mrs[i]
2278		lead := checks[m.ID]
2279		lead.kind = kindGlyph
2280		lead.s, _, _ = strings.Cut(lead.s, " ")
2281		if r := review[m.ID]; r.sgr == sgrYellow {
2282			lead = cYou()
2283		}
2284		branch := d.Source
2285		if d.TargetRef != repo.DefaultBranch {
2286			branch += " → " + d.TargetRef
2287		}
2288		ref := cLink(fmt.Sprintf("!%d", d.Number), c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10)))
2289		sec.rows = append(sec.rows, rowOf(ref, lead, cFlex(d.Title), cMeta(branch, review[m.ID].s, relAge(m.UpdatedAt, termNow()))))
2290	}
2291	s := screen{sections: []section{sec}}
2292	s.actions = append(s.actions, action{"New", []string{"mr", "create", repo.Path()}})
2293	other := "all"
2294	if state == "all" {
2295		other = "open"
2296	}
2297	s.actions = append(s.actions, action{"Filter", []string{"mr", "list", repo.Path(), "--state", other}})
2298	return s
2299}
2300```
2301
2302`issueListScreen` follows the same shape: lead `cYou()` when `assigneesMark` would colour the viewer yellow, otherwise `cGlyph(d.State)`; text the title; meta labels, assignees, comment count (`"3 comments"`), age. `buildListScreen`: lead `cGlyph(d.Status)`, ref the number, text `job  ref`, meta subject and age; one action, `Read: build show <path> <latest number>` when the list is non-empty.
2303
2304Callers: replace each terminal branch with `emitPageView(p, ds, next, plain, func() screen { ... })`, keeping the plain closures byte-for-byte. `mrListTerm` is deleted once nothing calls it. An empty list at a terminal still prints "nothing to list" to stderr (that is `emit`'s), so the builders never see zero rows from a real call.
2305
2306- [ ] **Step 4: Run tests**
2307
2308Run: `go test ./internal/control/ -run 'TestListsPlainPinned|TestMRListScreen|TestIssueListScreen|TestBuildListScreen'`
2309Expected: PASS.
2310
2311- [ ] **Step 5: Commit**
2312
2313```bash
2314git add internal/control/mr.go internal/control/issue.go internal/control/build.go internal/control/listscreen_test.go internal/control/testdata/plain/
2315git commit -m "control: mr, issue and build lists as screens
2316
2317Ref #318"
2318```
2319
2320### Task 15: `dashboard` screen
2321
2322**Files:**
2323- Modify: `internal/control/dashboard.go` (`runDashboard` from line 140; delete `needsYou` once unused)
2324- Test: `internal/control/dashscreen_test.go` (new), `testdata/plain/dashboard.txt`
2325
2326**Interfaces:**
2327- Produces: `func dashboardScreen(c *Ctx, d DashboardOut, lines []FeedLine) screen`. Check the element type `FeedLines` returns (`grep -n 'func FeedLines' internal/control/feedline.go`) and use it.
2328
2329- [ ] **Step 1: Pin piped output**
2330
2331```go
2332func TestDashboardPlainPinned(t *testing.T) {
2333	st, _, owner := twoMRTestRepo(t)
2334	c, out, errOut := mrTestCtx(st, owner)
2335	if code := Dispatch(c, []string{"dashboard"}); code != protocol.ExitOK {
2336		t.Fatalf("exit %d: %s", code, errOut)
2337	}
2338	pinPlain(t, "dashboard", out.String())
2339}
2340```
2341
2342Run with `-update-plain`, then without.
2343
2344- [ ] **Step 2: Failing screen tests**
2345
2346```go
2347func dashFixture() DashboardOut {
2348	return DashboardOut{
2349		Assigned: []DashboardItem{{Repo: "krz/skunky-art", Number: 33, Title: "LibRedirect listing", Author: "cmc"}},
2350		MRs:      []DashboardItem{{Repo: "krz/gitbay", Number: 552, Title: "wire $PAGER", Author: "cmc"}},
2351		Builds: []DashboardBuild{
2352			{Repo: "krz/omaha-metro-blotter", Number: 83, Job: "daily-pull", Status: "failure", Ref: "main", CreatedAt: "2026-09-30T12:00:00Z"},
2353			{Repo: "krz/gitbay", Number: 1779, Job: "test", Status: "success", Ref: "main", CreatedAt: "2026-10-01T10:00:00Z"},
2354			{Repo: "krz/gitbay", Number: 1778, Job: "build", Status: "success", Ref: "main", CreatedAt: "2026-10-01T10:00:00Z"},
2355		},
2356		Server: &ServerOut{Commit: "135f300"},
2357	}
2358}
2359
2360func TestDashboardScreen(t *testing.T) {
2361	c := screenCtx(110, false)
2362	c.User = store.User{Username: "cmc", IsAdmin: true}
2363	s := dashboardScreen(c, dashFixture(), nil)
2364	var titles []string
2365	for _, sec := range s.sections {
2366		if len(sec.rows) > 0 {
2367			titles = append(titles, sec.title)
2368		}
2369	}
2370	want := []string{"Assigned issues", "Open merge requests", "Failed builds"}
2371	if !slices.Equal(titles, want) {
2372		t.Errorf("sections = %q, want %q", titles, want)
2373	}
2374	for _, f := range s.fields {
2375		if f.label == "Server" || f.label == "Queues" {
2376			t.Errorf("admin block %q on the dashboard screen", f.label)
2377		}
2378	}
2379	var first []string
2380	for _, a := range s.actions {
2381		first = append(first, strings.Join(a.argv, " "))
2382	}
2383	if !slices.Contains(first, "issue show krz/skunky-art 33") || !slices.Contains(first, "build log krz/omaha-metro-blotter 83") {
2384		t.Errorf("actions = %q", first)
2385	}
2386	if !slices.Contains(first, "admin stats") {
2387		t.Errorf("admin missing admin stats: %q", first)
2388	}
2389	checkActions(t, s)
2390}
2391
2392func TestDashboardScreenPassingBuildsCollapse(t *testing.T) {
2393	c := screenCtx(110, false)
2394	d := dashFixture()
2395	d.Builds = d.Builds[1:]
2396	s := dashboardScreen(c, d, nil)
2397	for _, sec := range s.sections {
2398		if sec.title == "Failed builds" && len(sec.rows) > 0 {
2399			t.Error("failed builds section with no failures")
2400		}
2401	}
2402	if !strings.Contains(renderString(c, s), "2 builds passed") {
2403		t.Errorf("no passing-build summary:\n%s", renderString(c, s))
2404	}
2405}
2406```
2407
2408Expected on run: `undefined: dashboardScreen`.
2409
2410- [ ] **Step 3: Implement**
2411
2412```go
2413// dashboardScreen is dashboard at a terminal: what waits on the viewer
2414// first, then their open merge requests, failed builds, a few lines of
2415// activity and the pinned repositories. The operator's queues are
2416// admin stats'; a mirror or queue failure shows as one header line.
2417func dashboardScreen(c *Ctx, d DashboardOut, lines []FeedLine) screen {
2418	var s screen
2419	s.fields = append(s.fields, field{"User", []cell{cText(c.User.Username)}}, field{"Instance", []cell{cText(c.Cfg.SiteHost())}})
2420	if q := d.Queues; q != nil {
2421		if bad := q.Webhooks.Failed + q.Mail.Failed + q.Push.Failed + q.Mirrors.Errors + q.Deps.Errors; bad > 0 {
2422			s.fields = append(s.fields, field{"Problems", []cell{cGlyph("failed"), cText(fmt.Sprintf("%d failing in the background", bad))}})
2423		}
2424	}
2425	if d.Unread > 0 {
2426		s.fields = append(s.fields, field{"Inbox", []cell{cYou(), cText(fmt.Sprintf("%d unread", d.Unread))}})
2427	}
2428
2429	item := func(it DashboardItem, marker, page string, you bool) row {
2430		lead := cell{kind: kindGlyph}
2431		if you {
2432			lead = cYou()
2433		}
2434		ref := cLink(fmt.Sprintf("%s%s%d", it.Repo, marker, it.Number), c.siteURL(it.Repo, page, strconv.FormatInt(it.Number, 10)))
2435		return rowOf(ref, lead, cFlex(it.Title), cMeta(it.Author))
2436	}
2437	reviews := section{title: "Review requested", n: len(d.Reviews)}
2438	for _, it := range d.Reviews {
2439		reviews.rows = append(reviews.rows, item(it, "!", "mrs", true))
2440	}
2441	assigned := section{title: "Assigned issues", n: len(d.Assigned)}
2442	for _, it := range d.Assigned {
2443		assigned.rows = append(assigned.rows, item(it, "#", "issues", true))
2444	}
2445	mrs := section{title: "Open merge requests", n: len(d.MRs)}
2446	for _, it := range d.MRs {
2447		mrs.rows = append(mrs.rows, item(it, "!", "mrs", false))
2448	}
2449
2450	failed := section{title: "Failed builds"}
2451	passed := 0
2452	seen := map[string]bool{}
2453	var firstFailed *DashboardBuild
2454	for i, b := range d.Builds {
2455		key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref
2456		if seen[key] {
2457			continue
2458		}
2459		seen[key] = true
2460		switch b.Status {
2461		case "success":
2462			passed++
2463		case "failure", "failed", "error":
2464			failed.n++
2465			failed.rows = append(failed.rows, rowOf(cLink(strconv.FormatInt(b.Number, 10), c.siteURL(b.Repo, "builds", strconv.FormatInt(b.Number, 10))),
2466				cGlyph(b.Status), cFlex(b.Job+"  "+b.Ref), cMeta(b.Repo, relAge(b.CreatedAt, termNow()))))
2467			if firstFailed == nil {
2468				firstFailed = &d.Builds[i]
2469			}
2470		}
2471	}
2472
2473	activity := section{title: "Recent activity", n: len(lines), more: []string{"feed"}}
2474	for _, l := range lines[:min(len(lines), 5)] {
2475		activity.rows = append(activity.rows, rowOf(l.termCells(c)...))
2476	}
2477	if passed > 0 {
2478		activity.rows = append(activity.rows, rowOf(cMeta(fmt.Sprintf("%d builds passed", passed))))
2479	}
2480
2481	pinned := section{title: "Pinned", n: len(d.Pinned)}
2482	if len(d.Pinned) > 0 {
2483		var paths []string
2484		for _, p := range d.Pinned {
2485			paths = append(paths, p.Path)
2486		}
2487		pinned.rows = []row{rowOf(cMeta(strings.Join(paths, "  ")))}
2488	}
2489	s.sections = []section{reviews, assigned, mrs, failed, activity, pinned}
2490
2491	if len(d.Reviews) > 0 {
2492		it := d.Reviews[0]
2493		s.actions = append(s.actions, action{"Next", []string{"mr", "show", it.Repo, strconv.FormatInt(it.Number, 10)}})
2494	}
2495	if len(d.Assigned) > 0 {
2496		it := d.Assigned[0]
2497		s.actions = append(s.actions, action{"Next", []string{"issue", "show", it.Repo, strconv.FormatInt(it.Number, 10)}})
2498	}
2499	if firstFailed != nil {
2500		s.actions = append(s.actions, action{"Next", []string{"build", "log", firstFailed.Repo, strconv.FormatInt(firstFailed.Number, 10)}})
2501	}
2502	s.actions = append(s.actions, action{"More", []string{"feed"}})
2503	if c.User.IsAdmin {
2504		s.actions = append(s.actions, action{"Instance", []string{"admin", "stats"}})
2505	}
2506	return s
2507}
2508```
2509
2510Notes:
2511- `l.termCells(c)` is the feed's existing terminal row; check its column order and, if it leads with an age, keep it — activity rows are the one place time leads.
2512- With no feed lines but passing builds, the section has `n: 0` and one row, so it renders as `Recent activity (0)` above the summary. If that reads wrong, title the summary row's section `Builds` instead. Decide on the rendered output.
2513- Saved query sections (`d.Queries`) become one section each, titled with the query name and `n: q.Count`, rows as `item` without a lead, `more: []string{"query", "show", q.Name}` (check the real subcommand with `ssh git@gitbay.org help query`). Insert them after `mrs`.
2514- `Cfg.SiteHost()` exists (`repo.go` uses it).
2515
2516In `runDashboard`, compute `lines := FeedLines(events)` before emitting (it is computed inside the closure today), and switch to `c.emitView(d, plain, func() screen { return dashboardScreen(c, d, lines) })`. In the plain closure, delete every `c.Term.Cols > 0` branch (the `needsYou` line, the terminal activity rows, the empty-section skip) so the closure is the plain path only; the pin from step 1 proves it unchanged. Delete `needsYou` if nothing else calls it, and its test.
2517
2518- [ ] **Step 4: Run tests**
2519
2520Run: `go test ./internal/control/ -run 'TestDashboard'`
2521Expected: PASS, pin unchanged.
2522
2523- [ ] **Step 5: Verify, commit, MR**
2524
2525```bash
2526go build ./... && go vet ./... && go test ./internal/control/ ./internal/gitutil/ ./cmd/gitbay/
2527git add internal/control/dashboard.go internal/control/dashscreen_test.go internal/control/testdata/plain/dashboard.txt CHANGELOG.org
2528git commit -m "control: dashboard as a screen
2529
2530Ref #318"
2531git push -u origin cli-views-2b
2532gitbay mr create --source cli-views-2b --target main --title "control: list and dashboard screens (stage 2b of CLI views)"
2533```
2534
2535---
2536
2537## Stage 3
2538
2539Written as its own plan after stage 2b merges, from the `screen` API as it
2540stands then. Its scope: every remaining `c.table` and `c.view` caller
2541(start with `profile`, `release`, `milestone`, `org`, `label`), the header
2542lines of `mr diff`, `repo diff`, `repo commit` and `build log`, and removing
2543`view`'s terminal branches.
2544
2545## Self-review notes
2546
2547- Spec coverage: visual rules → tasks 1–4; model and `emitView` → 3, 5;
2548  `here=` → 4, 6; paging → 5; resolve test → 7; plain pinning → 7 and the
2549  first step of 10–15; screens → 10–15; wiki → 8; stage 3 deferred to its
2550  own plan, as the spec's stages allow.
2551- Two places in tasks 10 and 15 are marked "decide on the rendered output"
2552  (the Files count, the activity count). They are presentation choices with
2553  both options spelled out, not missing work.