Commit a14e94fa35

a14e94fa35092611d62b26b1e84ada08b86e0dee

parent: ee26be756c

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 03:00 UTC

docs/plans: CLI views, stages 1 and 2

Spec corrections found while planning: no browse command, CLI-local
legend commands, here= for the inferred repository, header rows kept
for numeric tables, comment bodies under discussion rows.

Layout: unified · split

docs/plans/2026-10-01-cli-views.md added +2553
@@ -0,0 +1,2553 @@
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.
docs/specs/2026-10-01-cli-views-design.md +26 −15
@@ -1,6 +1,6 @@
1# CLI views 1# CLI views
2 2
3Status: proposed, 2026-10-01. Follows `2026-10-01-cli-terminal-output-design.md` 3Status: accepted, 2026-10-01. Follows `2026-10-01-cli-terminal-output-design.md`
4(v1.41.0, #312–#315). 4(v1.41.0, #312–#315).
5 5
6## Problem 6## Problem
@@ -90,8 +90,12 @@ Nothing else is coloured. Refs and paths lose their cyan.
90 90
91**Legend.** A rule line, then up to three columns of action groups. Each 91**Legend.** A rule line, then up to three columns of action groups. Each
92group is a bold name over its commands, blue. Below 80 columns the groups 92group is a bold name over its commands, blue. Below 80 columns the groups
93stack in one column. Commands omit `<owner/name>` when the screen is for 93stack in one column. Commands omit `<owner/name>` when it equals the repository the CLI
94the repository the CLI would infer, so each line is what the user types. 94inferred from its clone, which the CLI sends as `here=<owner/name>` in
95`--term` (an older server ignores the option). Commands may name
96CLI-local commands (`mr rebase`, `mr checkout`). There is no `browse`:
97the header's first field links to the page where the terminal shows
98links, and a `URL:` field carries it otherwise.
95 99
96## Model 100## Model
97 101
@@ -117,14 +121,19 @@ type field struct {
117type section struct { 121type section struct {
118 title string 122 title string
119 n int // total, shown as "(n)" 123 n int // total, shown as "(n)"
120 rows [][]cell 124 rows []row // cells, plus a markup body drawn beneath (a comment)
121 more string // command for the rest, when n > len(rows) 125 more []string // command for the rest, when n > len(rows)
122 empty bool // draw "(0)" rather than omit 126 empty bool // draw "(0)" rather than omit
123} 127}
124 128
129type row struct {
130 cells []cell
131 body string
132 format string
133}
134
125type action struct { 135type action struct {
126 group string 136 group string
127 label string
128 argv []string 137 argv []string
129} 138}
130``` 139```
@@ -145,7 +154,9 @@ command, replacing the `more:` line.
145 154
146The width logic in `table.flush` (`fit`, `capSparse`, `dropEmpty`) moves 155The width logic in `table.flush` (`fit`, `capSparse`, `dropEmpty`) moves
147into the section renderer. At a terminal, `table` renders as one untitled 156into the section renderer. At a terminal, `table` renders as one untitled
148section, so lists that have not migrated take the new row style in stage 1. 157section, so lists that have not migrated take the new row style in stage 1. Such a
158table keeps a dim header row only when a column is a number or size
159(`admin runners`, `admin stats`), which is unreadable without one.
149Piped `table` output is unchanged. `view`'s terminal branches are removed 160Piped `table` output is unchanged. `view`'s terminal branches are removed
150once no command reaches them. 161once no command reaches them.
151 162
@@ -176,19 +187,19 @@ command; a field without data is omitted.
176- Header: `Merge`, `State`, `Checks`, `Review`, `Gates` (each 187- Header: `Merge`, `State`, `Checks`, `Review`, `Gates` (each
177 `MergeGates` gate as a glyph). 188 `MergeGates` gate as a glyph).
178- Body: description. 189- Body: description.
179- Sections: `Commits` (SHA, signature glyph, subject), `Files` with 190- Sections: `Commits` (SHA, subject), `Files` with `+a −d`,
180 `+a −d`, `Discussion` (threads as who · age and the first line; 191 `Discussion` (each comment as who · age with its body beneath; shown
181 suggestions marked; shown when empty). 192 when empty).
182- Legend: Unblock (`mr rebase`; `mr checkout` on a conflict), Review (approve, comment, 193- Legend: Unblock (`mr rebase` when behind), Review (`mr review
183 `apply-suggestion` when there are suggestions), Merge (when the gates 194 --approve`, `mr comment`), Merge (when the gates pass), Read
184 pass), Read (`mr diff`, `browse`). 195 (`mr diff`).
185 196
186**`issue show`** 197**`issue show`**
187 198
188- Header: `Issue`, `State`, `Labels` (swatches), `Assignee`, 199- Header: `Issue`, `State`, `Labels` (swatches), `Assignee`,
189 `Milestone`, `Linked` (merge requests that close it). 200 `Milestone`, `Linked` (merge requests that close it).
190- Body: description. 201- Body: description.
191- Sections: `Discussion`. 202- Sections: `Discussion` as on `mr show`.
192- Legend: comment, assign, label, close or reopen, filtered by permission. 203- Legend: comment, assign, label, close or reopen, filtered by permission.
193 204
194**`repo show`** 205**`repo show`**
@@ -197,7 +208,7 @@ command; a field without data is omitted.
197 (only on error). 208 (only on error).
198- Body: description and topics. 209- Body: description and topics.
199- Sections: `Open merge requests`, `Open issues`, `Recent commits` (5). 210- Sections: `Open merge requests`, `Open issues`, `Recent commits` (5).
200- Legend: `mr create`, `issue create`, `repo log`, `browse`. 211- Legend: `mr create`, `issue create`, `repo log`.
201 212
202**`build show`** 213**`build show`**
203 214