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

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

2553 lines · 84575 bytes

CLI views implementation plan (stages 1 and 2)

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.

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.

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.

Tech Stack: Go, the internal/control registry, internal/termtext for markup, git via internal/gitutil.

Spec: docs/specs/2026-10-01-cli-views-design.md

Issues: #316 (tracking), #317 (stage 1), #318 (stage 2), #319 (stage 3)

Global Constraints

  • Piped output and --json are byte-identical before and after every task.
  • All rendering is server-side in internal/control; cmd/gitbay only adds here= to --term.
  • Colour is never the only signal; stripSGR of a colour render equals the colourless render.
  • 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.
  • Glyphs: ✓ passed/ok, ✗ failed/blocked, ◐ running/pending, ● waiting on you, ○ closed/draft.
  • Commit messages end with Ref #N / Closes #N for the stage issue. No attribution lines.
  • Locally: go build ./..., go vet ./..., go test ./internal/control/ ./internal/gitutil/ ./cmd/gitbay/. The e2e suite runs in CI.
  • Commits are signed (the repository requires it); MRs merge with --strategy ff.

Corrections to the spec found while planning

These are applied to the spec in the same commit as this plan:

  1. There is no browse command. The legend never offers one; the header's first field links to the page (OSC 8) where the terminal shows links, and a URL: field carries it otherwise.
  2. mr rebase, mr checkout and repo clone are CLI-local (cmd/gitbay/local.go), not registry commands. The legend may name them; the resolve test accepts them from a fixed list.
  3. The server cannot see the client's working directory. The CLI sends here=<owner/name> in --term when it is inside a clone it can infer; legend commands drop an argument equal to it.
  4. Actions carry no label: the legend prints group names and commands.
  5. A table that has not migrated keeps its dim header row when it has a number or size column (admin runners, admin stats): without a header those columns are unreadable. Every other table drops it.
  6. mr show commits carry no signature glyph: CommitOut has no signature state, and verifying each commit on every show is not worth the cost. repo log already shows it.
  7. Discussion rows carry their full body beneath them. A show screen that printed only first lines would lose what issue show prints today.

File structure

File Responsibility
internal/control/screen.go (new) screen, field, section, row, action; render; legend layout; cmdline
internal/control/screen_test.go (new) renderer goldens, legend layout, checkActions helper
internal/control/plain_test.go (new) pinPlain golden helper for piped output
internal/control/testdata/plain/*.txt (new) pinned piped output per migrated command
internal/control/table.go new cell kinds, header rule, dim refs, lines() split from flush()
internal/control/term.go sgrBlue, glyph(), Term.Here
internal/control/view.go ref painted dim in title
internal/control/control.go emitView
internal/control/cursor.go emitPageView, nextArgv, restyled next-page line
internal/gitutil/numstat.go (new) DiffNumstat
internal/control/mr.go, issue.go, repo.go, build.go, dashboard.go screen builders and emitView calls
cmd/gitbay/ssh.go here=
.gitbay/wiki/Users.org "Output rules" rewritten
CHANGELOG.org unreleased entries

Stage 1 (one MR, Ref #317)

Task 1: Palette, glyph cells, dim refs

Files:

  • Modify: internal/control/term.go (const block at line 53, add glyph)
  • Modify: internal/control/table.go (cell kinds, constructors, joinRow, fit)
  • Modify: internal/control/view.go:99,106
  • Test: internal/control/term_test.go, internal/control/table_test.go

Interfaces:

  • Produces: sgrBlue; glyph(state string) (g, sgr string); kindGlyph, kindMeta; cGlyph(state string) cell; cYou() cell; cMeta(parts ...string) cell.

  • Step 1: Write failing tests

Append to internal/control/term_test.go:

func TestGlyph(t *testing.T) {
	for _, c := range []struct{ state, g, sgr string }{
		{"success", "✓", sgrGreen},
		{"approved", "✓", sgrGreen},
		{"merged", "✓", ""},
		{"failure", "✗", sgrRed},
		{"changes requested", "✗", sgrRed},
		{"signed_key_revoked", "✗", sgrRed},
		{"running", "◐", ""},
		{"pending", "◐", ""},
		{"closed", "○", ""},
		{"draft", "○", ""},
		{"open", "", ""},
		{"", "", ""},
	} {
		g, sgr := glyph(c.state)
		if g != c.g || sgr != c.sgr {
			t.Errorf("glyph(%q) = %q %q, want %q %q", c.state, g, sgr, c.g, c.sgr)
		}
	}
}

Append to internal/control/table_test.go:

func TestRefsDimNotCyan(t *testing.T) {
	var b strings.Builder
	tb := (&Ctx{Term: Term{Cols: 80, Color: true}}).table(&b, "#", "TITLE")
	tb.row(cRef("#12"), cFlex("Android app"))
	tb.flush()
	if strings.Contains(b.String(), sgrCyan) {
		t.Errorf("ref painted cyan: %q", b.String())
	}
	if !strings.Contains(b.String(), sgrDim+"#12"+sgrReset) {
		t.Errorf("ref not dim: %q", b.String())
	}
}

func TestGlyphAndMetaCells(t *testing.T) {
	var b strings.Builder
	tb := (&Ctx{Term: Term{Cols: 80, Color: true}}).table(&b)
	tb.row(cRef("1779"), cGlyph("failure"), cFlex("test"), cMeta("gitbay", "2h"))
	tb.row(cRef("1780"), cYou(), cFlex("review"), cMeta("", "3h"))
	tb.flush()
	out := b.String()
	for _, want := range []string{sgrRed + "✗" + sgrReset, sgrYellow + "●" + sgrReset, sgrDim + "gitbay · 2h" + sgrReset, sgrDim + "3h" + sgrReset} {
		if !strings.Contains(out, want) {
			t.Errorf("missing %q in %q", want, out)
		}
	}
}
  • Step 2: Run to confirm failure

Run: go test ./internal/control/ -run 'TestGlyph|TestRefsDimNotCyan|TestGlyphAndMetaCells' Expected: build failure, undefined: glyph, undefined: cGlyph.

  • Step 3: Implement

In term.go, add sgrBlue = "\x1b[34m" to the const block after sgrYellow, and after stateColor:

// glyph is the mark a screen puts first in a row or field for a state
// word, and its colour: ✓ passed, ✗ failed or blocked, ◐ still going,
// ○ closed or draft. A state with no mark ("open") returns "".
func glyph(state string) (g, sgr string) {
	switch state {
	case "merged":
		return "✓", ""
	case "success", "ok", "approved", "verified", "passed":
		return "✓", sgrGreen
	case "failure", "failed", "error", "changes requested",
		"bad_signature", "signed_email_mismatch", "signed_key_expired", "signed_key_revoked":
		return "✗", sgrRed
	case "pending", "running", "queued":
		return "◐", ""
	case "closed", "draft", "canceled", "cancelled", "skipped":
		return "○", ""
	}
	return "", ""
}

In table.go, extend the kind list and constructors:

const (
	kindText cellKind = iota
	kindFlex
	kindRef
	kindState
	kindAge
	kindNum
	kindSize
	kindSwatch
	kindGlyph
	kindMeta
)
// cGlyph is a state's mark, coloured for the state.
func cGlyph(state string) cell {
	g, sgr := glyph(state)
	return cell{kind: kindGlyph, s: g, sgr: sgr}
}

// cYou is the mark for a row that waits on the viewer.
func cYou() cell { return cell{kind: kindGlyph, s: "●", sgr: sgrYellow} }

// cMeta is a row's trailing facts, dim and joined by " · ". Empty parts
// are skipped.
func cMeta(parts ...string) cell {
	var keep []string
	for _, p := range parts {
		if p != "" {
			keep = append(keep, p)
		}
	}
	return cell{kind: kindMeta, s: strings.Join(keep, " · ")}
}

In joinRow, replace the kindRef case and add two cases:

			case kindRef:
				s = t.term.link(r[i].url, t.term.paint(sgrDim, s))
			case kindGlyph:
				s = t.term.paint(r[i].sgr, s)
			case kindMeta:
				s = t.term.paint(sgrDim, s)

In fit, let metadata shrink with text:

	for i := len(kinds) - 1; i >= 0; i-- {
		if kinds[i] == kindText || kinds[i] == kindMeta {
			shrink(i)
		}
	}

In view.go, lines 99 and 106, replace sgrCyan with sgrDim.

  • Step 4: Run tests

Run: go test ./internal/control/ Expected: 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.

  • Step 5: Commit
git add internal/control/term.go internal/control/table.go internal/control/view.go internal/control/*_test.go
git commit -m "control: glyph and meta cells, refs dim

Ref #317"

Task 2: Header rows only where columns need naming

Files:

  • Modify: internal/control/table.go (flush, new numeric, new lines)
  • Test: internal/control/table_test.go

Interfaces:

  • Produces: func (t *table) lines() []string (terminal rows, padded and painted, header omitted); flush keeps its signature.

  • Step 1: Write failing tests

func TestTableDropsHeaderWithoutNumbers(t *testing.T) {
	var b strings.Builder
	tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "#", "STATE", "TITLE")
	tb.row(cRef("#12"), cState("open"), cFlex("Android app"))
	tb.flush()
	if got, want := b.String(), "#12  open  Android app\n"; got != want {
		t.Errorf("got %q, want %q", got, want)
	}
}

func TestTableKeepsHeaderForNumbers(t *testing.T) {
	var b strings.Builder
	tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "RUNNER", "PENDING")
	tb.row(cText("bay1"), cNum(3))
	tb.flush()
	if !strings.HasPrefix(b.String(), "RUNNER  PENDING\n") {
		t.Errorf("header missing: %q", b.String())
	}
}

func TestTableLines(t *testing.T) {
	tb := (&Ctx{Term: Term{Cols: 80}}).table(io.Discard)
	tb.row(cRef("!1"), cFlex("one"))
	tb.row(cRef("!22"), cFlex("two"))
	got := tb.lines()
	want := []string{"!1   one", "!22  two"}
	if !slices.Equal(got, want) {
		t.Errorf("got %q, want %q", got, want)
	}
}

Add "io" and "slices" to the test file's imports if missing.

  • Step 2: Run to confirm failure

Run: go test ./internal/control/ -run 'TestTableDropsHeader|TestTableKeepsHeader|TestTableLines' Expected: FAIL (header printed; lines undefined).

  • Step 3: Implement

Replace flush with:

func (t *table) flush() {
	if t.term.Cols == 0 || len(t.rows) == 0 {
		return
	}
	var b strings.Builder
	for _, l := range t.lines() {
		b.WriteString(l + "\n")
	}
	io.WriteString(t.w, b.String())
}

// numeric reports whether a column holds numbers or sizes, which need a
// header to say what they count.
func (t *table) numeric() bool {
	for _, r := range t.rows {
		for _, c := range r {
			if c.kind == kindNum || c.kind == kindSize {
				return true
			}
		}
	}
	return false
}

// lines lays the rows out at the terminal width: a dim header first
// only when a column is a number, then each row padded and painted.
func (t *table) lines() []string {
	if !t.numeric() {
		t.header = nil
	}
	t.dropEmpty()
	n := len(t.header)
	for _, r := range t.rows {
		n = max(n, len(r))
	}
	widths := make([]int, n)
	for i, h := range t.header {
		widths[i] = cells(h)
	}
	for _, r := range t.rows {
		for i := 0; i < len(r); i++ {
			widths[i] = max(widths[i], cells(r[i].s))
		}
	}
	t.capSparse(widths)
	t.fit(widths)

	var out []string
	line := make([]string, n)
	if len(t.header) > 0 {
		for i := range line {
			line[i] = ""
			if i < len(t.header) {
				line[i] = clip(t.header[i], widths[i])
			}
		}
		out = append(out, t.term.paint(sgrDim, strings.TrimRight(t.join(line, widths), " ")))
	}
	for _, r := range t.rows {
		for i := 0; i < n; i++ {
			s := ""
			if i < len(r) {
				s = clip(r[i].s, widths[i])
			}
			line[i] = s
		}
		out = append(out, strings.TrimRight(t.joinRow(r, line, widths), " "))
	}
	return out
}

Update the comment above type table struct to say the header is written only when a column is numeric.

  • Step 4: Run tests

Run: go test ./internal/control/ Expected: 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.

  • Step 5: Commit
git add internal/control/table.go internal/control/*_test.go
git commit -m "control: tables name their columns only when they hold numbers

Ref #317"

Task 3: The screen model and renderer

Files:

  • Create: internal/control/screen.go
  • Create: internal/control/screen_test.go

Interfaces:

  • Consumes: table.lines(), cGlyph, cMeta, cYou, sgrBlue (tasks 1–2).
  • Produces:
type screen struct {
	fields   []field
	body     string
	format   string
	sections []section
	actions  []action
}
type field struct {
	label string
	value []cell
}
type section struct {
	title string
	n     int
	rows  []row
	more  []string
	empty bool
}
type row struct {
	cells  []cell
	body   string
	format string
}
type action struct {
	group string
	argv  []string
}
func (c *Ctx) render(w io.Writer, s screen)
func rowOf(cs ...cell) row // a row with no body
  • Step 1: Write failing tests

internal/control/screen_test.go:

package control

import (
	"strings"
	"testing"
	"time"
)

func screenCtx(cols int, color bool) *Ctx {
	return &Ctx{Term: Term{Cols: cols, Color: color}}
}

func renderString(c *Ctx, s screen) string {
	var b strings.Builder
	c.render(&b, s)
	return b.String()
}

func sampleScreen() screen {
	return screen{
		fields: []field{
			{"Merge", []cell{cRef("!552"), cText("wire $PAGER through long views")}},
			{"State", []cell{cState("open"), cMeta("cli-pager → main", "cmc")}},
			{"Checks", []cell{cGlyph("running"), cText("1 running")}},
		},
		body:   "Pages long views.",
		format: "markdown",
		sections: []section{
			{title: "Commits", n: 2, rows: []row{
				rowOf(cRef("8f3a1c2"), cFlex("cli: page long output")),
				rowOf(cRef("2b77e90"), cFlex("control: mark views")),
			}},
			{title: "Discussion", n: 0, empty: true},
			{title: "Hidden", n: 0},
		},
	}
}

func TestRenderPlainLayout(t *testing.T) {
	got := renderString(screenCtx(80, false), sampleScreen())
	want := `Merge:   !552  wire $PAGER through long views
State:   open  cli-pager → main · cmc
Checks:  ◐  1 running

Pages long views.

Commits (2)
8f3a1c2  cli: page long output
2b77e90  control: mark views

Discussion (0)
`
	if got != want {
		t.Errorf("got:\n%s\nwant:\n%s", got, want)
	}
}

func TestRenderColourIsOnlyPaint(t *testing.T) {
	for _, cols := range []int{80, 120} {
		plain := renderString(screenCtx(cols, false), sampleScreen())
		colour := renderString(screenCtx(cols, true), sampleScreen())
		if stripSGR(colour) != plain {
			t.Errorf("cols %d: colour render differs beyond SGR:\n%s\n---\n%s", cols, stripSGR(colour), plain)
		}
		if !strings.Contains(colour, sgrBold+sgrBlue+"Commits (2)"+sgrReset) {
			t.Errorf("cols %d: heading not bold blue: %q", cols, colour)
		}
		if !strings.Contains(colour, sgrDim+"Merge:"+sgrReset) {
			t.Errorf("cols %d: label not dim: %q", cols, colour)
		}
	}
}

func TestRenderMoreLine(t *testing.T) {
	s := screen{sections: []section{{title: "Builds", n: 14, more: []string{"build", "list", "krz/gitbay"},
		rows: []row{rowOf(cRef("1779"), cFlex("test"))}}}}
	got := renderString(screenCtx(80, false), s)
	if !strings.HasSuffix(got, "1779  test\n+13 more  gitbay build list krz/gitbay\n") {
		t.Errorf("got %q", got)
	}
}

func TestRenderRowBody(t *testing.T) {
	s := screen{sections: []section{{title: "Discussion", n: 1, rows: []row{
		{cells: []cell{cText("cmc"), cMeta("2h")}, body: "Looks good.", format: "markdown"},
	}}}}
	got := renderString(screenCtx(80, false), s)
	want := "Discussion (1)\ncmc  2h\n  Looks good.\n"
	if got != want {
		t.Errorf("got %q, want %q", got, want)
	}
}

func TestRenderFieldWraps(t *testing.T) {
	long := strings.Repeat("word ", 20)
	s := screen{fields: []field{{"Merge", []cell{cRef("!1"), cText(strings.TrimSpace(long))}}}}
	for _, l := range strings.Split(strings.TrimRight(renderString(screenCtx(40, false), s), "\n"), "\n") {
		if cells(l) > 40 {
			t.Errorf("line wider than 40: %q", l)
		}
	}
}

func init() { termNow = func() time.Time { return time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) } }

If termNow is already pinned by another test file's init, drop the init here.

  • Step 2: Run to confirm failure

Run: go test ./internal/control/ -run 'TestRender' Expected: build failure, undefined: screen.

  • Step 3: Implement internal/control/screen.go
package control

import (
	"fmt"
	"io"
	"strings"

	"gitbay.org/krz/gitbay/internal/termtext"
)

// screen is a show or a list as a terminal draws it: a header block of
// labelled fields, a body, counted sections, and the commands that
// apply. Commands build one only at a terminal; plain and --json output
// never reach it.
type screen struct {
	fields   []field
	body     string // markup source
	format   string // the body's markup format
	sections []section
	actions  []action
}

// field is one header line: a label and the cells after it.
type field struct {
	label string
	value []cell
}

// section is a titled run of rows. n is the total, shown as "(n)"; when
// it is more than the rows shown, more is the command for the rest. An
// empty section is left out unless empty says to draw "(0)".
type section struct {
	title string
	n     int
	rows  []row
	more  []string
	empty bool
}

// row is a section row, and optionally the markup that follows it (a
// comment's body).
type row struct {
	cells  []cell
	body   string
	format string
}

func rowOf(cs ...cell) row { return row{cells: cs} }

// action is one command in the legend, under its group's name. argv is
// the command as typed after "gitbay".
type action struct {
	group string
	argv  []string
}

// render draws s: the parts in order, one blank line between parts and
// between sections.
func (c *Ctx) render(w io.Writer, s screen) {
	var blocks []string
	if b := c.renderFields(s.fields); b != "" {
		blocks = append(blocks, b)
	}
	if b := c.renderBody(s.body, s.format, ""); b != "" {
		blocks = append(blocks, b)
	}
	for _, sec := range s.sections {
		if b := c.renderSection(sec); b != "" {
			blocks = append(blocks, b)
		}
	}
	if b := c.renderLegend(s.actions); b != "" {
		blocks = append(blocks, b)
	}
	io.WriteString(w, strings.Join(blocks, "\n"))
}

// renderFields aligns labels on the widest, dim, with a colon. Every
// cell but the last is painted as a table paints it; the last wraps to
// the width, continuation lines under its first column.
func (c *Ctx) renderFields(fs []field) string {
	t := c.Term
	wide := 0
	for _, f := range fs {
		if len(f.value) > 0 {
			wide = max(wide, cells(f.label)+1)
		}
	}
	var b strings.Builder
	for _, f := range fs {
		if len(f.value) == 0 {
			continue
		}
		prefix := t.paint(sgrDim, f.label+":") + strings.Repeat(" ", wide-cells(f.label)-1+2)
		col := wide + 2
		head := ""
		for _, cl := range f.value[:len(f.value)-1] {
			s := c.cellText(cl)
			if s == "" {
				continue
			}
			head += c.paintCell(cl, s) + "  "
			col += cells(s) + 2
		}
		last := f.value[len(f.value)-1]
		lines := termtext.Wrap(c.cellText(last), max(8, t.Cols-col))
		indent := strings.Repeat(" ", col)
		for i, l := range lines {
			p := indent
			if i == 0 {
				p = prefix + head
			}
			b.WriteString(strings.TrimRight(p+c.paintCell(last, l), " ") + "\n")
		}
	}
	return b.String()
}

// cellText is a cell's terminal text: made safe, ages relative, sizes
// humanized, as table.row prepares it.
func (c *Ctx) cellText(cl cell) string {
	s := termSafe(cl.s)
	switch cl.kind {
	case kindAge:
		return relAge(s, termNow())
	case kindSize:
		var n int64
		if _, err := fmt.Sscan(s, &n); err == nil {
			return humanBytes(n)
		}
	}
	return s
}

// paintCell paints text s as its cell's kind paints at a terminal.
func (c *Ctx) paintCell(cl cell, s string) string {
	t := c.Term
	switch cl.kind {
	case kindState:
		return t.paintState(s)
	case kindRef:
		return t.link(cl.url, t.paint(sgrDim, s))
	case kindMeta:
		return t.paint(sgrDim, s)
	case kindSwatch:
		return t.swatch(s)
	}
	return t.paint(cl.sgr, s)
}

// renderBody renders markup to the width, each line prefixed by indent.
func (c *Ctx) renderBody(src, format, indent string) string {
	if strings.TrimSpace(src) == "" {
		return ""
	}
	opts := termtext.Options{Width: max(0, c.Term.Cols-len(indent)), Color: c.Term.Color, Base: c.Cfg.Server.SiteURL}
	var b strings.Builder
	for _, l := range strings.Split(strings.TrimRight(termtext.Render(termSafe(src), format, opts), "\n"), "\n") {
		if l == "" {
			b.WriteString("\n")
			continue
		}
		b.WriteString(indent + l + "\n")
	}
	return b.String()
}

// renderSection is the heading, the rows laid out as one table with no
// header, each row's body indented beneath it, and the "+n more" line.
func (c *Ctx) renderSection(s section) string {
	if len(s.rows) == 0 && !s.empty {
		return ""
	}
	var b strings.Builder
	b.WriteString(c.Term.paint(sgrBold+sgrBlue, fmt.Sprintf("%s (%d)", s.title, s.n)) + "\n")
	tb := &table{term: c.Term, w: io.Discard}
	for _, r := range s.rows {
		tb.row(append([]cell(nil), r.cells...)...)
	}
	if len(s.rows) > 0 {
		tb.header = nil
		for i, l := range tb.lines() {
			b.WriteString(l + "\n")
			b.WriteString(c.renderBody(s.rows[i].body, s.rows[i].format, "  "))
		}
	}
	if rest := s.n - len(s.rows); rest > 0 && len(s.more) > 0 {
		b.WriteString(c.Term.paint(sgrDim, fmt.Sprintf("+%d more  %s", rest, c.cmdline(s.more))) + "\n")
	}
	return b.String()
}

renderLegend and cmdline come in task 4; add stubs now so the package builds:

func (c *Ctx) renderLegend(as []action) string { return "" }

func (c *Ctx) cmdline(argv []string) string { return "gitbay " + strings.Join(argv, " ") }

Check the module path in go.mod and use it in the termtext import.

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.

  • Step 4: Run tests

Run: go test ./internal/control/ -run 'TestRender' Expected: PASS. If TestRenderPlainLayout differs only in the field padding, the rule is: label, colon, then spaces to wide+2 columns (wide = longest label + 1).

  • Step 5: Commit
git add internal/control/screen.go internal/control/screen_test.go
git commit -m "control: screen model and renderer

Ref #317"

Task 4: Legend, cmdline, and here=

Files:

  • Modify: internal/control/screen.go (replace the two stubs)
  • Modify: internal/control/term.go (Term.Here, ParseTerm)
  • Test: internal/control/screen_test.go, internal/control/term_test.go

Interfaces:

  • Produces: Term.Here string; func (c *Ctx) cmdline(argv []string) string; func (c *Ctx) renderLegend(as []action) string.

  • Step 1: Write failing tests

In term_test.go, add cases to TestParseTerm's table (match its existing struct shape):

		{"120,color,here=krz/gitbay", Term{Cols: 120, Color: true, Here: "krz/gitbay"}},
		{"120,here=", Term{Cols: 120}},

In screen_test.go:

func TestCmdlineDropsHere(t *testing.T) {
	c := screenCtx(80, false)
	c.Term.Here = "krz/gitbay"
	if got, want := c.cmdline([]string{"mr", "diff", "krz/gitbay", "552"}), "gitbay mr diff 552"; got != want {
		t.Errorf("got %q, want %q", got, want)
	}
	if got, want := c.cmdline([]string{"issue", "show", "krz/hutch", "3"}), "gitbay issue show krz/hutch 3"; got != want {
		t.Errorf("got %q, want %q", got, want)
	}
	if got, want := c.cmdline([]string{"issue", "list", "--label", "needs review"}), "gitbay issue list --label 'needs review'"; got != want {
		t.Errorf("got %q, want %q", got, want)
	}
}

func legendSample() []action {
	return []action{
		{"Unblock", []string{"mr", "rebase", "552"}},
		{"Review", []string{"mr", "review", "krz/gitbay", "552", "--approve"}},
		{"Review", []string{"mr", "comment", "krz/gitbay", "552"}},
		{"Read", []string{"mr", "diff", "krz/gitbay", "552"}},
	}
}

func TestLegendColumns(t *testing.T) {
	c := screenCtx(120, false)
	c.Term.Here = "krz/gitbay"
	got := c.renderLegend(legendSample())
	want := strings.Repeat("─", 120) + "\n" +
		"Unblock               Review                        Read\n" +
		"gitbay mr rebase 552  gitbay mr review 552 --approve  gitbay mr diff 552\n" +
		"                      gitbay mr comment 552\n"
	if got != want {
		t.Errorf("got:\n%s\nwant:\n%s", got, want)
	}
}

func TestLegendStacksWhenNarrow(t *testing.T) {
	c := screenCtx(60, false)
	c.Term.Here = "krz/gitbay"
	got := c.renderLegend(legendSample())
	want := strings.Repeat("─", 60) + "\n" +
		"Unblock\ngitbay mr rebase 552\n" +
		"Review\ngitbay mr review 552 --approve\ngitbay mr comment 552\n" +
		"Read\ngitbay mr diff 552\n"
	if got != want {
		t.Errorf("got:\n%s\nwant:\n%s", got, want)
	}
}

func TestLegendColour(t *testing.T) {
	c := screenCtx(120, true)
	got := c.renderLegend(legendSample())
	if !strings.Contains(got, sgrBlue+"gitbay mr rebase 552"+sgrReset) || !strings.Contains(got, sgrBold+"Unblock"+sgrReset) {
		t.Errorf("legend paint: %q", got)
	}
	if stripSGR(got) != screenCtx(120, false).renderLegend(legendSample()) {
		t.Error("colour legend differs beyond SGR")
	}
}
  • Step 2: Run to confirm failure

Run: go test ./internal/control/ -run 'TestParseTerm|TestCmdline|TestLegend' Expected: FAIL (Here unknown field; stub output).

  • Step 3: Implement

In term.go, add to Term:

	// Here is the repository the CLI inferred from the clone it runs in,
	// so a suggested command can leave it out.
	Here string

and in ParseTerm's loop, before switch opt:

		if v, ok := strings.CutPrefix(opt, "here="); ok {
			t.Here = v
			continue
		}

In screen.go, replace the stubs:

// cmdline is argv as the viewer would type it: "gitbay", the words
// shell-quoted, and the repository the CLI inferred left out.
func (c *Ctx) cmdline(argv []string) string {
	words := []string{"gitbay"}
	for _, a := range argv {
		if c.Term.Here != "" && a == c.Term.Here {
			continue
		}
		words = append(words, shellWord(a))
	}
	return strings.Join(words, " ")
}

// renderLegend is a rule, then the action groups in the order they first
// appear, each a bold name over its commands in blue. Groups sit side by
// side, three to a band, when the terminal is 80 wide or more and the
// band fits; otherwise they stack.
func (c *Ctx) renderLegend(as []action) string {
	if len(as) == 0 {
		return ""
	}
	t := c.Term
	type group struct {
		name string
		cmds []string
	}
	var groups []*group
	byName := map[string]*group{}
	for _, a := range as {
		g := byName[a.group]
		if g == nil {
			g = &group{name: a.group}
			byName[a.group] = g
			groups = append(groups, g)
		}
		g.cmds = append(g.cmds, c.cmdline(a.argv))
	}
	var b strings.Builder
	b.WriteString(t.paint(sgrDim, strings.Repeat("─", t.Cols)) + "\n")
	for start := 0; start < len(groups); start += 3 {
		band := groups[start:min(start+3, len(groups))]
		widths := make([]int, len(band))
		total, height := 0, 0
		for i, g := range band {
			widths[i] = cells(g.name)
			for _, cmd := range g.cmds {
				widths[i] = max(widths[i], cells(cmd))
			}
			total += widths[i]
			height = max(height, len(g.cmds))
		}
		total += 2 * (len(band) - 1)
		if t.Cols < 80 || total > t.Cols {
			for _, g := range band {
				b.WriteString(t.paint(sgrBold, g.name) + "\n")
				for _, cmd := range g.cmds {
					b.WriteString(t.paint(sgrBlue, cmd) + "\n")
				}
			}
			continue
		}
		for line := -1; line < height; line++ {
			var l strings.Builder
			for i, g := range band {
				s, paint := "", sgrBlue
				if line < 0 {
					s, paint = g.name, sgrBold
				} else if line < len(g.cmds) {
					s = g.cmds[line]
				}
				if i < len(band)-1 {
					l.WriteString(t.paint(paint, s) + strings.Repeat(" ", widths[i]-cells(s)+2))
				} else {
					l.WriteString(t.paint(paint, s))
				}
			}
			b.WriteString(strings.TrimRight(l.String(), " ") + "\n")
		}
	}
	return b.String()
}

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.

  • Step 4: Run tests

Run: go test ./internal/control/ Expected: PASS.

  • Step 5: Commit
git add internal/control/screen.go internal/control/term.go internal/control/*_test.go
git commit -m "control: action legend; here= names the inferred repository

Ref #317"

Task 5: emitView, emitPageView, the next-page line

Files:

  • Modify: internal/control/control.go (after emit)
  • Modify: internal/control/cursor.go (emitPage)
  • Test: internal/control/screen_test.go, internal/control/cursor_test.go:84

Interfaces:

  • Consumes: render, cmdline (tasks 3–4).
  • Produces:
func (c *Ctx) emitView(data any, plain func(w io.Writer), build func() screen) int
func (c *Ctx) emitPageView(p page, items any, next string, plain func(w io.Writer), build func() screen) int
func (c *Ctx) nextArgv(next string) []string
  • Step 1: Write failing tests
func TestEmitViewRoutes(t *testing.T) {
	built := false
	build := func() screen { built = true; return screen{fields: []field{{"Repo", []cell{cText("a/b")}}}} }
	plain := func(w io.Writer) { io.WriteString(w, "plain\n") }

	var out bytes.Buffer
	c := &Ctx{Stdout: &out, Stderr: io.Discard}
	c.emitView(map[string]string{"k": "v"}, plain, build)
	if out.String() != "plain\n" || built {
		t.Errorf("piped: %q built=%v", out.String(), built)
	}

	out.Reset()
	c = &Ctx{Stdout: &out, Stderr: io.Discard, JSON: true, Term: Term{Cols: 80}}
	c.emitView(map[string]string{"k": "v"}, plain, build)
	if !strings.Contains(out.String(), `"k":"v"`) || built {
		t.Errorf("json: %q built=%v", out.String(), built)
	}

	out.Reset()
	c = &Ctx{Stdout: &out, Stderr: io.Discard, Term: Term{Cols: 80}}
	c.emitView(map[string]string{"k": "v"}, plain, build)
	if out.String() != "Repo:  a/b\n" || !built {
		t.Errorf("terminal: %q built=%v", out.String(), built)
	}
}

Add bytes and io imports to screen_test.go.

In cursor_test.go, change the expectation at line 84 to the new stderr line:

	want := "Next page  gitbay build list " + repo.Path() + " --limit 2 --cursor "
  • Step 2: Run to confirm failure

Run: go test ./internal/control/ -run 'TestEmitViewRoutes|TestCursor' Expected: FAIL (emitView undefined; old more: text).

  • Step 3: Implement

In control.go, after emit:

// emitView is emit for a command with a terminal screen: --json and
// piped output are emit's, and at a terminal build's screen is drawn
// in place of plain.
func (c *Ctx) emitView(data any, plain func(w io.Writer), build func() screen) int {
	if c.Term.Cols == 0 || c.JSON {
		return c.emit(data, plain)
	}
	return c.emit(data, func(w io.Writer) { c.render(w, build()) })
}

In cursor.go, extract the argv the old more: line built:

// nextArgv is the command that fetches the page after this one: this
// command's argv with its cursor replaced.
func (c *Ctx) nextArgv(next string) []string {
	argv := strings.Fields(joinPath(c.Cmd.Path))
	for i := 0; i < len(c.Argv); i++ {
		if c.Argv[i] == "--cursor" {
			i++
			continue
		}
		argv = append(argv, c.Argv[i])
	}
	return append(argv, "--cursor", next)
}

and replace the terminal branch of emitPage's closure (from var again []string to the Fprintf) with:

		fmt.Fprintln(c.Stderr, c.Term.paint(sgrBold, "Next page")+"  "+c.Term.paint(sgrBlue, c.cmdline(c.nextArgv(next))))

Then add:

// emitPageView is emitPage for a list with a terminal screen.
func (c *Ctx) emitPageView(p page, items any, next string, plain func(w io.Writer), build func() screen) int {
	if c.Term.Cols == 0 || c.JSON {
		return c.emitPage(p, items, next, plain)
	}
	return c.emitPage(p, items, next, func(w io.Writer) { c.render(w, build()) })
}

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.

  • Step 4: Run tests

Run: go test ./internal/control/ Expected: PASS.

  • Step 5: Commit
git add internal/control/control.go internal/control/cursor.go internal/control/*_test.go
git commit -m "control: emitView and emitPageView; next page as a legend line

Ref #317"

Task 6: The CLI sends here=

Files:

  • Modify: cmd/gitbay/ssh.go (termValue signature and runSSHPaged)
  • Test: cmd/gitbay/term_test.go

Interfaces:

  • Produces: func termValue(isTerminal bool, cols int, here string, env func(string) string) string.

  • Step 1: Write failing test

Add to cmd/gitbay/term_test.go:

func TestTermValueHere(t *testing.T) {
	env := func(string) string { return "" }
	if got, want := termValue(true, 100, "krz/gitbay", env), "100,color,here=krz/gitbay"; got != want {
		t.Errorf("got %q, want %q", got, want)
	}
	if got := termValue(true, 100, "", env); strings.Contains(got, "here=") {
		t.Errorf("here= without a repository: %q", got)
	}
	if got := termValue(false, 100, "krz/gitbay", env); got != "" {
		t.Errorf("piped: %q", got)
	}
}

Update 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)).

  • Step 2: Run to confirm failure

Run: go test ./cmd/gitbay/ -run TestTermValue Expected: build failure (too many arguments).

  • Step 3: Implement

Change termValue to take here and append it last:

func termValue(isTerminal bool, cols int, here string, env func(string) string) string {
	// ... existing body up to the links check unchanged ...
	if linksWanted(env) {
		v += ",links"
	}
	if here != "" {
		v += ",here=" + here
	}
	return v
}

Keep 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."

In runSSHPaged, pass t.repo:

	if v := termValue(isTTY, cols, t.repo, os.Getenv); v != "" && !slices.Contains(serverArgv, "--json") {
  • Step 4: Run tests

Run: go test ./cmd/gitbay/ Expected: PASS.

  • Step 5: Commit
git add cmd/gitbay/ssh.go cmd/gitbay/term_test.go
git commit -m "cli: send the inferred repository as here= in --term

Ref #317"

Task 7: Test helpers for migrations — checkActions and pinPlain

Files:

  • Modify: internal/control/screen_test.go
  • Create: internal/control/plain_test.go

Interfaces:

  • Produces (test-only): func checkActions(t *testing.T, s screen); func pinPlain(t *testing.T, name, got string); flag -update-plain.

  • Step 1: Write the helpers and their own tests

Append to screen_test.go:

// cliLocal are commands cmd/gitbay runs itself; the registry does not
// know them, but a legend may suggest them.
var cliLocal = map[string]bool{"mr rebase": true, "mr checkout": true, "repo clone": true}

// errorer is the part of *testing.T checkActions uses, so its own test
// can pass a recorder.
type errorer interface {
	Helper()
	Errorf(format string, args ...any)
}

type recorder struct{ failed bool }

func (r *recorder) Helper()                {}
func (r *recorder) Errorf(string, ...any) { r.failed = true }

// checkActions fails t for any legend or "more" command that the
// registry would not dispatch, or whose flags it would refuse.
func checkActions(t errorer, s screen) {
	t.Helper()
	var all [][]string
	for _, a := range s.actions {
		all = append(all, a.argv)
	}
	for _, sec := range s.sections {
		if len(sec.more) > 0 {
			all = append(all, sec.more)
		}
	}
	for _, argv := range all {
		if len(argv) >= 2 && cliLocal[argv[0]+" "+argv[1]] {
			continue
		}
		cmd, rest, ok := Lookup(argv)
		if !ok {
			t.Errorf("no command for %q", argv)
			continue
		}
		if err := checkFlags(cmd, rest); err != nil {
			t.Errorf("%q: %v", argv, err)
		}
	}
}

func TestCheckActionsCatchesTypos(t *testing.T) {
	r := &recorder{}
	checkActions(r, screen{actions: []action{{"Read", []string{"mr", "dif", "a/b", "1"}}}})
	if !r.failed {
		t.Error("a misspelled command passed")
	}
	r = &recorder{}
	checkActions(r, screen{actions: []action{{"Read", []string{"mr", "diff", "a/b", "1", "--bogus"}}}})
	if !r.failed {
		t.Error("an unknown flag passed")
	}
	r = &recorder{}
	checkActions(r, screen{actions: []action{{"Unblock", []string{"mr", "rebase", "1"}}}})
	if r.failed {
		t.Error("a CLI-local command failed")
	}
}

checkFlags reads the command's declared flags (Command.Flags, each with Name and Arg; Arg empty for a switch), so no store is involved. Add it to screen_test.go:

// checkFlags refuses a flag the command does not declare, and a value
// flag with nothing after it. Words that are not flags are positionals.
func checkFlags(cmd Command, rest []string) error {
	takes := map[string]bool{}
	for _, f := range cmd.Flags {
		takes[f.Name] = f.Arg != ""
	}
	for i := 0; i < len(rest); i++ {
		a := rest[i]
		if !strings.HasPrefix(a, "--") {
			continue
		}
		name, _, inline := strings.Cut(a, "=")
		value, ok := takes[name]
		if !ok {
			return fmt.Errorf("%s does not take %s", joinPath(cmd.Path), name)
		}
		if value && !inline {
			if i+1 >= len(rest) {
				return fmt.Errorf("%s needs a value", name)
			}
			i++
		}
	}
	return nil
}

If a command in a legend declares no Flags but accepts some (an older registration), add its Flags to the registration rather than loosening the check; TestCommandsDeclareFlags or its equivalent may already enforce this (grep -n 'Flags' internal/control/help_test.go).

Create internal/control/plain_test.go:

package control

import (
	"flag"
	"os"
	"path/filepath"
	"regexp"
	"testing"
)

var updatePlain = flag.Bool("update-plain", false, "rewrite testdata/plain from the current output")

var plainStamp = regexp.MustCompile(`\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?(Z| UTC)?`)

// pinPlain compares a command's piped output with the copy taken before
// its terminal screen was rewritten. Timestamps are masked; the fixture
// clock is the store's.
func pinPlain(t *testing.T, name, got string) {
	t.Helper()
	got = plainStamp.ReplaceAllString(got, "<time>")
	path := filepath.Join("testdata", "plain", name+".txt")
	if *updatePlain {
		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
			t.Fatal(err)
		}
		return
	}
	want, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("%v: capture it with -update-plain before changing the command", err)
	}
	if got != string(want) {
		t.Errorf("piped output changed:\n--- want\n%s--- got\n%s", want, got)
	}
}
  • Step 2: Run

Run: go test ./internal/control/ -run 'TestCheckActions' Expected: PASS.

  • Step 3: Commit
git add internal/control/screen_test.go internal/control/plain_test.go
git commit -m "control: test helpers for screen migrations

Ref #317"

Task 8: Docs, changelog, MR

Files:

  • Modify: .gitbay/wiki/Users.org ("Output rules" section)

  • Modify: CHANGELOG.org (unreleased section at the top; create one headed * Unreleased if the top entry is a released version)

  • Step 1: Rewrite "Output rules"

Replace 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.

  • Step 2: Changelog entry
- Terminal output is drawn as screens (#317): lists drop
  their header row unless a column is a number, refs are dim rather than
  cyan, and state shows as a leading glyph where a screen has one. The
  next page is a =Next page= line with the command. The CLI sends the
  repository it inferred as =here== in =--term=, so suggested commands
  leave it out; an older instance ignores it. Piped and =--json= output
  are unchanged.
  • Step 3: Verify and push

Run: go build ./... && go vet ./... && go test ./internal/control/ ./cmd/gitbay/ Expected: all PASS.

git add .gitbay/wiki/Users.org CHANGELOG.org
git commit -m "docs: screen output rules

Ref #317"
git push -u origin cli-views-1
gitbay mr create --source cli-views-1 --target main --title "control: screen renderer (stage 1 of CLI views)"

CI 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.


Stage 2a: show screens (one MR, Ref #318)

Each migration follows the same order: pin the piped output on the unchanged code, write the screen test, write the builder, switch the command to emitView, confirm the pin still matches.

Task 9: gitutil.DiffNumstat

Files:

  • Create: internal/gitutil/numstat.go
  • Test: internal/gitutil/numstat_test.go

Interfaces:

  • Produces:
type NumStat struct {
	Path    string
	Added   int // -1 for a binary file
	Deleted int // -1 for a binary file
	Status  string // "A", "M", "D", "R"
}
func DiffNumstat(dir, base, head string) ([]NumStat, error)
  • Step 1: Write failing test

Use 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:

	want := []NumStat{
		{Path: "a.txt", Added: 2, Deleted: 1, Status: "M"},
		{Path: "bin.dat", Added: -1, Deleted: -1, Status: "A"},
		{Path: "new.txt", Added: 1, Deleted: 0, Status: "A"},
	}
  • Step 2: Run to confirm failure

Run: go test ./internal/gitutil/ -run TestDiffNumstat Expected: undefined: DiffNumstat.

  • Step 3: Implement
package gitutil

import (
	"fmt"
	"os/exec"
	"strconv"
	"strings"

	"gitbay.org/krz/gitbay/internal/toolpath"
)

// NumStat is one file's line counts between two commits. A binary file
// counts -1 each way.
type NumStat struct {
	Path    string
	Added   int
	Deleted int
	Status  string
}

// DiffNumstat lists the files changed from base to head, sorted by path,
// with line counts and A/M/D/R status. Renames are detected.
func DiffNumstat(dir, base, head string) ([]NumStat, error) {
	run := func(args ...string) (string, error) {
		cmd := exec.Command(toolpath.Look("git"), append([]string{"-C", dir}, args...)...)
		out, err := cmd.Output()
		if err != nil {
			return "", fmt.Errorf("git %s: %w", args[0], err)
		}
		return string(out), nil
	}
	num, err := run("diff", "--numstat", "-z", "-M", "--end-of-options", base, head)
	if err != nil {
		return nil, err
	}
	names, err := run("diff", "--name-status", "-z", "-M", "--end-of-options", base, head)
	if err != nil {
		return nil, err
	}
	status := map[string]string{}
	f := strings.Split(strings.TrimSuffix(names, "\x00"), "\x00")
	for i := 0; i < len(f); {
		s := f[i]
		if strings.HasPrefix(s, "R") || strings.HasPrefix(s, "C") {
			if i+2 < len(f) {
				status[f[i+2]] = s[:1]
			}
			i += 3
			continue
		}
		if i+1 < len(f) {
			status[f[i+1]] = s[:1]
		}
		i += 2
	}
	var out []NumStat
	recs := strings.Split(strings.TrimSuffix(num, "\x00"), "\x00")
	for i := 0; i < len(recs); i++ {
		parts := strings.SplitN(recs[i], "\t", 3)
		if len(parts) != 3 {
			continue
		}
		path := parts[2]
		if path == "" && i+2 < len(recs) { // rename: "a\td\t" then old, new
			path = recs[i+2]
			i += 2
		}
		ns := NumStat{Path: path, Added: -1, Deleted: -1, Status: status[path]}
		if parts[0] != "-" {
			ns.Added, _ = strconv.Atoi(parts[0])
			ns.Deleted, _ = strconv.Atoi(parts[1])
		}
		out = append(out, ns)
	}
	return out, nil
}

Check the toolpath import path against merge.go's imports and match it.

  • Step 4: Run tests

Run: go test ./internal/gitutil/ -run TestDiffNumstat Expected: PASS.

  • Step 5: Commit
git add internal/gitutil/numstat.go internal/gitutil/numstat_test.go
git commit -m "gitutil: DiffNumstat

Ref #318"

Task 10: mr show screen

Files:

  • Modify: internal/control/mr.go (runMRShow at line 739; new mrShowScreen)
  • Test: internal/control/mrscreen_test.go (new), internal/control/testdata/plain/mr-show.txt (new)

Interfaces:

  • Consumes: screen types, emitView, cGlyph, cYou, cMeta, checksMark (marks.go:15), gitutil.DiffNumstat.

  • Produces: func mrShowScreen(c *Ctx, repo store.Repo, d MRShow, files []gitutil.NumStat) screen.

  • Step 1: Pin the piped output on unchanged code

internal/control/mrscreen_test.go:

package control

import (
	"testing"

	"gitbay.org/krz/gitbay/internal/gitutil"
	"gitbay.org/krz/gitbay/internal/protocol"
)

func TestMRShowPlainPinned(t *testing.T) {
	st, repo, owner := twoMRTestRepo(t)
	c, out, errOut := mrTestCtx(st, owner)
	if code := Dispatch(c, []string{"mr", "show", repo.Path(), "1"}); code != protocol.ExitOK {
		t.Fatalf("exit %d: %s", code, errOut)
	}
	pinPlain(t, "mr-show", out.String())
}

Run: go test ./internal/control/ -run TestMRShowPlainPinned -update-plain then go test ./internal/control/ -run TestMRShowPlainPinned Expected: PASS both times; testdata/plain/mr-show.txt exists.

  • Step 2: Write the failing screen tests
func mrShowFixture() MRShow {
	d := MRShow{mrOut: mrOut{Number: 552, Title: "wire $PAGER through long views", State: "open",
		Author: "cmc", Source: "cli-pager", TargetRef: "main", Body: "Pages long views.", BodyFormat: "markdown",
		CreatedAt: "2026-10-01T11:40:00Z"}}
	d.Commits = []CommitOut{{"8f3a1c2aaaaaaaa", "cli: page long output"}, {"2b77e90bbbbbbbb", "control: mark views"}}
	d.Checks = []CheckOut{{Context: "test", State: "pending"}, {Context: "build", State: "success"}}
	d.Gates = &GatesOut{ApprovalsRequired: 1, FastForward: false, Unmet: []string{"needs 1 approval", "behind main"}}
	return d
}

func actionArgvs(s screen, group string) [][]string {
	var out [][]string
	for _, a := range s.actions {
		if a.group == group {
			out = append(out, a.argv)
		}
	}
	return out
}

func TestMRShowScreenBehind(t *testing.T) {
	c := screenCtx(100, false)
	repo := store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"}
	s := mrShowScreen(c, repo, mrShowFixture(), []gitutil.NumStat{{Path: "cmd/gitbay/ssh.go", Added: 41, Deleted: 6, Status: "M"}})
	if got := actionArgvs(s, "Unblock"); len(got) != 1 || got[0][1] != "rebase" {
		t.Errorf("Unblock = %q, want mr rebase", got)
	}
	if got := actionArgvs(s, "Merge"); len(got) != 0 {
		t.Errorf("Merge offered with unmet gates: %q", got)
	}
	titles := map[string]int{}
	for _, sec := range s.sections {
		titles[sec.title] = sec.n
	}
	if titles["Commits"] != 2 || titles["Files"] != 1 {
		t.Errorf("sections = %v", titles)
	}
	checkActions(t, s)
}

func TestMRShowScreenReady(t *testing.T) {
	c := screenCtx(100, false)
	d := mrShowFixture()
	d.Gates = &GatesOut{FastForward: true}
	repo := store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"}
	s := mrShowScreen(c, repo, d, nil)
	if len(actionArgvs(s, "Unblock")) != 0 {
		t.Error("Unblock offered when nothing blocks")
	}
	if got := actionArgvs(s, "Merge"); len(got) != 1 || got[0][1] != "merge" {
		t.Errorf("Merge = %q", got)
	}
	checkActions(t, s)
}

func TestMRShowScreenClosedOffersNoWrites(t *testing.T) {
	c := screenCtx(100, false)
	d := mrShowFixture()
	d.State, d.Gates = "merged", nil
	s := mrShowScreen(c, store.Repo{OwnerName: "krz", Name: "gitbay"}, d, nil)
	for _, a := range s.actions {
		if a.group != "Read" {
			t.Errorf("merged MR offers %q", a.argv)
		}
	}
}

Add store to the imports.

Run: go test ./internal/control/ -run TestMRShowScreen Expected: undefined: mrShowScreen.

  • Step 3: Implement mrShowScreen

Add to mr.go after runMRShow:

// mrShowScreen is mr show at a terminal: where the merge request stands,
// its description, its commits, files and discussion, and what can be
// done about it now.
func mrShowScreen(c *Ctx, repo store.Repo, d MRShow, files []gitutil.NumStat) screen {
	n := strconv.FormatInt(d.Number, 10)
	path := repo.Path()
	state := d.State
	if d.Draft {
		state = "draft"
	}
	s := screen{body: d.Body, format: d.BodyFormat}

	s.fields = append(s.fields,
		field{"Merge", []cell{cLink("!"+n, c.siteURL(path, "mrs", n)), cText(d.Title)}},
		field{"State", []cell{cState(state), cMeta(d.Source+" → "+d.TargetRef, d.Author, relAge(d.CreatedAt, termNow()))}},
	)
	if len(d.Checks) > 0 {
		var sts []store.CommitStatus
		for _, x := range d.Checks {
			sts = append(sts, store.CommitStatus{Context: x.Context, State: x.State})
		}
		m := checksMark(sts)
		s.fields = append(s.fields, field{"Checks", []cell{m}})
	}
	open := d.State == "open" || d.State == "source_gone"
	behind := false
	if g := d.Gates; g != nil && open {
		var parts []string
		for _, u := range g.Unmet {
			parts = append(parts, "✗ "+u)
		}
		behind = !g.FastForward
		if len(parts) == 0 {
			s.fields = append(s.fields, field{"Gates", []cell{cMark("✓ ready to merge", sgrGreen)}})
		} else {
			s.fields = append(s.fields, field{"Gates", []cell{cMark(strings.Join(parts, " · "), sgrRed)}})
		}
	}
	if len(d.ReviewRequests) > 0 {
		v := cText("requested of " + strings.Join(d.ReviewRequests, ", "))
		if slices.Contains(d.ReviewRequests, c.User.Username) {
			v.sgr = sgrYellow
		}
		s.fields = append(s.fields, field{"Review", []cell{v}})
	}
	if d.Milestone != "" {
		s.fields = append(s.fields, field{"Milestone", []cell{cText(d.Milestone)}})
	}
	if len(d.Labels) > 0 {
		s.fields = append(s.fields, field{"Labels", []cell{cText(strings.Join(d.Labels, ", "))}})
	}
	if !c.Term.Links {
		s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "mrs", n))}})
	}

	commits := section{title: "Commits", n: len(d.Commits)}
	for _, cm := range d.Commits {
		commits.rows = append(commits.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject)))
	}
	add, del := 0, 0
	filesSec := section{n: len(files)}
	for _, f := range files {
		counts := "binary"
		if f.Added >= 0 {
			add, del = add+f.Added, del+f.Deleted
			counts = fmt.Sprintf("+%d −%d", f.Added, f.Deleted)
		}
		filesSec.rows = append(filesSec.rows, rowOf(cRef(f.Status), cFlex(f.Path), cMeta(counts)))
	}
	filesSec.title = fmt.Sprintf("Files  +%d −%d", add, del)
	if len(files) == 0 {
		filesSec.title = "Files"
	}
	disc := section{title: "Discussion", empty: true}
	for _, cm := range d.Comments {
		if cm.Kind == "system" {
			continue
		}
		disc.n++
		disc.rows = append(disc.rows, row{cells: []cell{cText(cm.Author), cMeta(relAge(cm.CreatedAt, termNow()))}, body: cm.Body, format: cm.BodyFormat})
	}
	s.sections = []section{commits, filesSec, disc}

	if open {
		if behind {
			s.actions = append(s.actions, action{"Unblock", []string{"mr", "rebase", n}})
		}
		s.actions = append(s.actions,
			action{"Review", []string{"mr", "review", path, n, "--approve"}},
			action{"Review", []string{"mr", "comment", path, n}},
		)
		if d.Gates != nil && len(d.Gates.Unmet) == 0 {
			s.actions = append(s.actions, action{"Merge", []string{"mr", "merge", path, n}})
		}
	}
	s.actions = append(s.actions, action{"Read", []string{"mr", "diff", path, n}})
	return s
}

Notes for the implementer:

  • mr rebase is CLI-local and infers the repository from the clone, so it takes only the number.
  • 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.

In runMRShow, compute files only at a terminal and switch to emitView:

	var files []gitutil.NumStat
	if c.Term.Cols > 0 && !c.JSON && base != "" {
		files, _ = gitutil.DiffNumstat(dir, base, mrHeadRef(mr.Number))
	}
	return c.emitView(d, func(w io.Writer) {
		// existing plain closure, unchanged
	}, func() screen { return mrShowScreen(c, repo, d, files) })
  • Step 4: Run tests

Run: go test ./internal/control/ -run 'TestMRShow' Expected: all PASS, including TestMRShowPlainPinned against the pin taken in step 1.

  • Step 5: Look at it

Build and run against a scratch instance or production read-only:

go build -o /tmp/gitbay ./cmd/gitbay && /tmp/gitbay mr show krz/gitbay 551

The 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.

  • Step 6: Commit
git add internal/control/mr.go internal/control/mrscreen_test.go internal/control/testdata/plain/mr-show.txt
git commit -m "control: mr show as a screen

Ref #318"

Task 11: issue show screen

Files:

  • Modify: internal/control/issue.go (runIssueShow at line 381; new issueShowScreen)
  • Test: internal/control/issuescreen_test.go (new), testdata/plain/issue-show.txt

Interfaces:

  • Produces: func issueShowScreen(c *Ctx, repo store.Repo, d IssueShow, canWrite bool) screen.

  • Step 1: Pin piped output

func TestIssueShowPlainPinned(t *testing.T) {
	st, repo, uid := newQueueTestRepo(t)
	owner := store.User{ID: uid, Username: "alice"}
	id, err := st.CreateIssue(repo.ID, uid, "Android app", "An app.", "markdown")
	if err != nil {
		t.Fatal(err)
	}
	if err := st.AddIssueComment(id, uid, "Started.", "markdown"); err != nil {
		t.Fatal(err)
	}
	c, out, errOut := mrTestCtx(st, owner)
	if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK {
		t.Fatalf("exit %d: %s", code, errOut)
	}
	pinPlain(t, "issue-show", out.String())
}

Run with -update-plain, then without. Expected: PASS.

  • Step 2: Failing screen tests
func issueFixture() IssueShow {
	return IssueShow{issueOut: issueOut{Number: 12, Title: "Android app", State: "open", Author: "cmc",
		Labels: []string{"mobile"}, Assignees: []string{"alice"}, Body: "An app.", BodyFormat: "markdown",
		CreatedAt: "2026-08-21T10:00:00Z"},
		Comments: []commentOut{{ID: 1, Author: "cmc", Body: "Started.", BodyFormat: "markdown", CreatedAt: "2026-09-01T10:00:00Z", Kind: "comment"},
			{ID: 2, Author: "cmc", Body: "labelled mobile", CreatedAt: "2026-09-01T10:00:00Z", Kind: "system"}}}
}

func TestIssueShowScreen(t *testing.T) {
	c := screenCtx(100, false)
	c.User = store.User{Username: "alice"}
	repo := store.Repo{OwnerName: "krz", Name: "gitbay"}
	s := issueShowScreen(c, repo, issueFixture(), true)
	if len(s.sections) != 1 || s.sections[0].title != "Discussion" || s.sections[0].n != 1 {
		t.Errorf("sections = %+v", s.sections)
	}
	var verbs []string
	for _, a := range s.actions {
		verbs = append(verbs, a.argv[1])
	}
	if !slices.Equal(verbs, []string{"comment", "assign", "label", "close"}) {
		t.Errorf("actions = %q", verbs)
	}
	checkActions(t, s)
}

func TestIssueShowScreenReaderOnlyComments(t *testing.T) {
	c := screenCtx(100, false)
	c.User = store.User{Username: "bob"}
	s := issueShowScreen(c, store.Repo{OwnerName: "krz", Name: "gitbay"}, issueFixture(), false)
	if len(s.actions) != 1 || s.actions[0].argv[1] != "comment" {
		t.Errorf("reader actions = %+v", s.actions)
	}
}

Expected on run: undefined: issueShowScreen.

  • Step 3: Implement
// issueShowScreen is issue show at a terminal. canWrite is write access
// or authorship: what lets the viewer close, assign and label.
func issueShowScreen(c *Ctx, repo store.Repo, d IssueShow, canWrite bool) screen {
	n := strconv.FormatInt(d.Number, 10)
	path := repo.Path()
	s := screen{body: d.Body, format: d.BodyFormat}
	s.fields = append(s.fields,
		field{"Issue", []cell{cLink("#"+n, c.siteURL(path, "issues", n)), cText(d.Title)}},
		field{"State", []cell{cState(d.State), cMeta(d.Author, relAge(d.CreatedAt, termNow()))}},
	)
	if len(d.Labels) > 0 {
		s.fields = append(s.fields, field{"Labels", []cell{cText(strings.Join(d.Labels, ", "))}})
	}
	if len(d.Assignees) > 0 {
		v := cText(strings.Join(d.Assignees, ", "))
		if slices.Contains(d.Assignees, c.User.Username) {
			v.sgr = sgrYellow
		}
		s.fields = append(s.fields, field{"Assignee", []cell{v}})
	}
	if d.Milestone != "" {
		s.fields = append(s.fields, field{"Milestone", []cell{cText(d.Milestone)}})
	}
	if !c.Term.Links {
		s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "issues", n))}})
	}
	disc := section{title: "Discussion", empty: true}
	for _, cm := range d.Comments {
		if cm.Kind == "system" {
			continue
		}
		disc.n++
		disc.rows = append(disc.rows, row{cells: []cell{cText(cm.Author), cMeta(relAge(cm.CreatedAt, termNow()))}, body: cm.Body, format: cm.BodyFormat})
	}
	s.sections = []section{disc}

	s.actions = append(s.actions, action{"Discuss", []string{"issue", "comment", path, n}})
	if canWrite {
		s.actions = append(s.actions,
			action{"Triage", []string{"issue", "assign", path, n, "--add", c.User.Username}},
			action{"Triage", []string{"issue", "label", path, n, "--add", "<label>"}},
		)
		verb := "close"
		if d.State == "closed" {
			verb = "reopen"
		}
		s.actions = append(s.actions, action{"State", []string{"issue", verb, path, n}})
	}
	return s
}

Check 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.

In 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) }).

  • Step 4: Run tests

Run: go test ./internal/control/ -run 'TestIssueShow' Expected: PASS.

  • Step 5: Commit
git add internal/control/issue.go internal/control/issuescreen_test.go internal/control/testdata/plain/issue-show.txt
git commit -m "control: issue show as a screen

Ref #318"

Task 12: repo show screen

Files:

  • Modify: internal/control/repo.go (runRepoShow at line 376, repoGlance, new repoShowScreen)
  • Test: internal/control/reposcreen_test.go (new), testdata/plain/repo-show.txt

Interfaces:

  • 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.

  • Step 1: Pin piped output

func TestRepoShowPlainPinned(t *testing.T) {
	st, repo, uid := newQueueTestRepo(t)
	c, out, errOut := mrTestCtx(st, store.User{ID: uid, Username: "alice"})
	if code := Dispatch(c, []string{"repo", "show", repo.Path()}); code != protocol.ExitOK {
		t.Fatalf("exit %d: %s", code, errOut)
	}
	pinPlain(t, "repo-show", out.String())
}

Run with -update-plain, then without. Expected: PASS. Then hoist the types; run again; still PASS.

  • Step 2: Failing screen test
func TestRepoShowScreen(t *testing.T) {
	c := screenCtx(100, false)
	d := repoShowOut{Path: "krz/gitbay", Description: "A CLI-first git forge.", Visibility: "public", DefaultBranch: "main",
		Topics: []string{"cli", "forge"}}
	g := repoGlance{clone: "ssh://git@gitbay.org/krz/gitbay.git", release: "v1.41.0, 2h ago", checks: "✓ 2/2 on main"}
	mrs := []store.MR{{Number: 552, Title: "wire $PAGER", UpdatedAt: "2026-10-01T11:40:00Z"}}
	s := repoShowScreen(c, d, g, mrs, nil, []CommitOut{{"a136534ff9", "changelog: v1.41.0"}})
	got := map[string]int{}
	for _, sec := range s.sections {
		got[sec.title] = sec.n
	}
	if got["Open merge requests"] != 1 || got["Recent commits"] != 1 {
		t.Errorf("sections = %v", got)
	}
	if _, ok := got["Open issues"]; ok && got["Open issues"] == 0 && len(s.sections) == 3 {
		// an empty section with no rows is not drawn; it may be present in the model
	}
	checkActions(t, s)
}

Expected on run: undefined: repoShowScreen.

  • Step 3: Implement

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.

// repoShowScreen is repo show at a terminal: how to clone it, where the
// default branch stands, what is open, and the latest commits.
func repoShowScreen(c *Ctx, d repoShowOut, g repoGlance, mrs []store.MR, issues []store.Issue, commits []CommitOut) screen {
	s := screen{body: d.Description, format: "text"}
	name := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility)}
	if d.Archived {
		name = append(name, cState("archived"))
	}
	s.fields = append(s.fields, field{"Repo", name}, field{"Clone", []cell{cText(g.clone)}})
	head := []cell{cText(d.DefaultBranch)}
	if g.checks != "" {
		head = append(head, cText(strings.TrimSuffix(g.checks, " on "+d.DefaultBranch)))
	}
	s.fields = append(s.fields, field{"Head", head})
	if g.release != "" {
		s.fields = append(s.fields, field{"Release", []cell{cText(g.release)}})
	}
	if len(d.Topics) > 0 {
		s.fields = append(s.fields, field{"Topics", []cell{cText(strings.Join(d.Topics, ", "))}})
	}
	if d.ForkOf != "" {
		s.fields = append(s.fields, field{"Fork of", []cell{cRef(d.ForkOf)}})
	}
	for _, m := range d.Mirrors {
		if m.LastError != "" {
			s.fields = append(s.fields, field{"Mirror", []cell{cGlyph("failed"), cText(m.Direction + " " + m.URL + ": " + m.LastError)}})
		}
	}
	if !c.Term.Links {
		s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(d.Path))}})
	}

	ms := section{title: "Open merge requests", n: g.mrsN, more: []string{"mr", "list", d.Path}}
	for _, m := range mrs {
		ms.rows = append(ms.rows, rowOf(cRef(fmt.Sprintf("!%d", m.Number)), cFlex(m.Title), cAge(m.UpdatedAt)))
	}
	is := section{title: "Open issues", n: g.issuesN, more: []string{"issue", "list", d.Path}}
	for _, i := range issues {
		is.rows = append(is.rows, rowOf(cRef(fmt.Sprintf("#%d", i.Number)), cFlex(i.Title), cAge(i.UpdatedAt)))
	}
	cs := section{title: "Recent commits", n: len(commits), more: []string{"repo", "log", d.Path}}
	for _, cm := range commits {
		cs.rows = append(cs.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject)))
	}
	s.sections = []section{ms, is, cs}
	s.actions = []action{
		{"Contribute", []string{"mr", "create", d.Path}},
		{"Contribute", []string{"issue", "create", d.Path}},
		{"Read", []string{"repo", "log", d.Path}},
	}
	return s
}

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.

  • Step 4: Run tests

Run: go test ./internal/control/ -run 'TestRepoShow' Expected: PASS, pin unchanged.

  • Step 5: Commit
git add internal/control/repo.go internal/control/reposcreen_test.go internal/control/testdata/plain/repo-show.txt
git commit -m "control: repo show as a screen

Ref #318"

Task 13: build show screen

Files:

  • Modify: internal/control/build.go (runBuildShow at line 240, new buildShowScreen)
  • Test: internal/control/buildscreen_test.go (new), testdata/plain/build-show.txt

Interfaces:

  • Produces: func buildShowScreen(c *Ctx, repo store.Repo, d BuildOut, mr *store.MR) screen.

  • Step 1: Pin piped output

func TestBuildShowPlainPinned(t *testing.T) {
	st, repo, uid := newQueueTestRepo(t)
	id, err := st.CreateBuild(repo.ID, "test", "a136534ff9a136534ff9a136534ff9a136534ff9", "main", `["go vet ./...","go test ./..."]`, "", "", true)
	if err != nil {
		t.Fatal(err)
	}
	if err := st.FinishBuild(id, "success"); err != nil {
		t.Fatal(err)
	}
	c, out, errOut := mrTestCtx(st, store.User{ID: uid, Username: "alice"})
	if code := Dispatch(c, []string{"build", "show", repo.Path(), "1"}); code != protocol.ExitOK {
		t.Fatalf("exit %d: %s", code, errOut)
	}
	pinPlain(t, "build-show", out.String())
}

If 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.

  • Step 2: Failing screen test
func TestBuildShowScreenFailed(t *testing.T) {
	c := screenCtx(100, false)
	d := BuildOut{Number: 83, Job: "daily-pull", Status: "failure", SHA: "355b04066b", Ref: "main",
		Steps: []string{"setup", "pull", "publish"}, FailedStep: 2, FailedReason: "exit 1", DurationS: 74}
	s := buildShowScreen(c, store.Repo{OwnerName: "krz", Name: "omaha-metro-blotter"}, d, nil)
	steps := s.sections[0]
	if steps.title != "Steps" || steps.n != 3 {
		t.Fatalf("steps = %+v", steps)
	}
	if g := steps.rows[1].cells[0]; g.s != "✗" {
		t.Errorf("failed step glyph = %q", g.s)
	}
	if g := steps.rows[2].cells[0]; g.s != "" {
		t.Errorf("step after the failure = %q, want no glyph", g.s)
	}
	if len(s.actions) != 1 || s.actions[0].argv[1] != "log" {
		t.Errorf("actions = %+v", s.actions)
	}
	checkActions(t, s)
}

Expected on run: undefined: buildShowScreen.

  • Step 3: Implement
// buildShowScreen is build show at a terminal: the build's outcome, what
// it ran on, each step's outcome, and the command for its log.
func buildShowScreen(c *Ctx, repo store.Repo, d BuildOut, mr *store.MR) screen {
	n := strconv.FormatInt(d.Number, 10)
	path := repo.Path()
	var s screen
	s.fields = append(s.fields,
		field{"Build", []cell{cLink(n, c.siteURL(path, "builds", n)), cText(d.Job)}},
		field{"State", []cell{cGlyph(d.Status), cState(d.Status)}},
		field{"Commit", []cell{cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Subject)}},
		field{"Ref", []cell{cText(d.Ref)}},
	)
	if mr != nil {
		s.fields = append(s.fields, field{"MR", []cell{cRef(fmt.Sprintf("!%d", mr.Number)), cText(mr.Title)}})
	}
	if d.DurationS > 0 {
		s.fields = append(s.fields, field{"Duration", []cell{cText(c.Term.dur(d.DurationS))}})
	}
	if !c.Term.Links {
		s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "builds", n))}})
	}
	steps := section{title: "Steps", n: len(d.Steps)}
	for i, step := range d.Steps {
		line, _, _ := strings.Cut(step, "\n")
		state := stepState(d.Status, d.FailedStep, i+1)
		meta := ""
		if i+1 == d.FailedStep {
			meta = d.FailedReason
		}
		steps.rows = append(steps.rows, rowOf(cGlyph(state), cFlex(line), cMeta(meta)))
	}
	s.sections = []section{steps}
	s.actions = []action{{"Read", []string{"build", "log", path, n}}}
	return s
}

Check 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.

  • Step 4: Run tests

Run: go test ./internal/control/ -run 'TestBuildShow' Expected: PASS, pin unchanged.

  • Step 5: Commit, verify, MR
git add internal/control/build.go internal/control/buildscreen_test.go internal/control/testdata/plain/build-show.txt
git commit -m "control: build show as a screen

Ref #318"
go build ./... && go vet ./... && go test ./internal/control/ ./internal/gitutil/
git push -u origin cli-views-2a
gitbay mr create --source cli-views-2a --target main --title "control: show screens (stage 2a of CLI views)"

Add a CHANGELOG line under the unreleased section naming the four screens before pushing.


Stage 2b: lists and dashboard (one MR, Ref #318)

Task 14: mr list, issue list, build list screens

Files:

  • Modify: internal/control/mr.go (mrListTerm at line 711 and its caller), issue.go (runIssueList terminal branch), build.go (runBuildList)
  • Test: internal/control/listscreen_test.go (new), testdata/plain/{mr,issue,build}-list.txt

Interfaces:

  • Produces:
func mrListScreen(c *Ctx, repo store.Repo, state string, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) screen
func issueListScreen(c *Ctx, repo store.Repo, state string, issues []store.Issue, ds []issueOut, comments map[int64]int, labels, assignees map[int64][]string) screen
func buildListScreen(c *Ctx, repo store.Repo, ds []BuildOut) screen
  • Step 1: Pin piped output for all three
func TestListsPlainPinned(t *testing.T) {
	st, repo, owner := twoMRTestRepo(t)
	if _, err := st.CreateIssue(repo.ID, owner.ID, "Android app", "", "markdown"); err != nil {
		t.Fatal(err)
	}
	if _, err := st.CreateBuild(repo.ID, "test", "abc111", "main", `["go test ./..."]`, "", "", true); err != nil {
		t.Fatal(err)
	}
	for name, argv := range map[string][]string{
		"mr-list":    {"mr", "list", repo.Path()},
		"issue-list": {"issue", "list", repo.Path()},
		"build-list": {"build", "list", repo.Path()},
	} {
		c, out, errOut := mrTestCtx(st, owner)
		if code := Dispatch(c, argv); code != protocol.ExitOK {
			t.Fatalf("%s: exit %d: %s", name, code, errOut)
		}
		pinPlain(t, name, out.String())
	}
}

Run with -update-plain, then without.

  • Step 2: Failing screen tests
func TestMRListScreen(t *testing.T) {
	c := screenCtx(100, false)
	c.User = store.User{Username: "alice"}
	repo := store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"}
	mrs := []store.MR{{ID: 1, Number: 552, UpdatedAt: "2026-10-01T11:40:00Z"}}
	ds := []mrOut{{Number: 552, Title: "wire $PAGER", State: "open", Source: "cli-pager", TargetRef: "main"}}
	checks := map[int64]cell{1: cMark("✓ 2/2", sgrGreen)}
	review := map[int64]cell{1: cMark("review requested", sgrYellow)}
	s := mrListScreen(c, repo, "open", mrs, ds, checks, review)
	if len(s.sections) != 1 || s.sections[0].title != "Open merge requests" || s.sections[0].n != 1 {
		t.Fatalf("sections = %+v", s.sections)
	}
	if g := s.sections[0].rows[0].cells[1]; g.s != "●" {
		t.Errorf("review requested of the viewer should lead with ●, got %q", g.s)
	}
	var verbs []string
	for _, a := range s.actions {
		verbs = append(verbs, strings.Join(a.argv[:2], " "))
	}
	if !slices.Equal(verbs, []string{"mr create", "mr list"}) {
		t.Errorf("actions = %q", verbs)
	}
	checkActions(t, s)
}

Write 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).

Expected on run: undefined builders.

  • Step 3: Implement
// mrListScreen is mr list at a terminal: one section of merge requests,
// each led by ● when it waits on the viewer or its checks' mark, then
// title, branch, review and age.
func mrListScreen(c *Ctx, repo store.Repo, state string, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) screen {
	title := map[string]string{"open": "Open merge requests", "closed": "Closed merge requests", "merged": "Merged merge requests", "all": "Merge requests"}[state]
	sec := section{title: title, n: len(ds)}
	for i, d := range ds {
		m := mrs[i]
		lead := checks[m.ID]
		lead.kind = kindGlyph
		lead.s, _, _ = strings.Cut(lead.s, " ")
		if r := review[m.ID]; r.sgr == sgrYellow {
			lead = cYou()
		}
		branch := d.Source
		if d.TargetRef != repo.DefaultBranch {
			branch += " → " + d.TargetRef
		}
		ref := cLink(fmt.Sprintf("!%d", d.Number), c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10)))
		sec.rows = append(sec.rows, rowOf(ref, lead, cFlex(d.Title), cMeta(branch, review[m.ID].s, relAge(m.UpdatedAt, termNow()))))
	}
	s := screen{sections: []section{sec}}
	s.actions = append(s.actions, action{"New", []string{"mr", "create", repo.Path()}})
	other := "all"
	if state == "all" {
		other = "open"
	}
	s.actions = append(s.actions, action{"Filter", []string{"mr", "list", repo.Path(), "--state", other}})
	return s
}

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.

Callers: 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.

  • Step 4: Run tests

Run: go test ./internal/control/ -run 'TestListsPlainPinned|TestMRListScreen|TestIssueListScreen|TestBuildListScreen' Expected: PASS.

  • Step 5: Commit
git add internal/control/mr.go internal/control/issue.go internal/control/build.go internal/control/listscreen_test.go internal/control/testdata/plain/
git commit -m "control: mr, issue and build lists as screens

Ref #318"

Task 15: dashboard screen

Files:

  • Modify: internal/control/dashboard.go (runDashboard from line 140; delete needsYou once unused)
  • Test: internal/control/dashscreen_test.go (new), testdata/plain/dashboard.txt

Interfaces:

  • 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.

  • Step 1: Pin piped output

func TestDashboardPlainPinned(t *testing.T) {
	st, _, owner := twoMRTestRepo(t)
	c, out, errOut := mrTestCtx(st, owner)
	if code := Dispatch(c, []string{"dashboard"}); code != protocol.ExitOK {
		t.Fatalf("exit %d: %s", code, errOut)
	}
	pinPlain(t, "dashboard", out.String())
}

Run with -update-plain, then without.

  • Step 2: Failing screen tests
func dashFixture() DashboardOut {
	return DashboardOut{
		Assigned: []DashboardItem{{Repo: "krz/skunky-art", Number: 33, Title: "LibRedirect listing", Author: "cmc"}},
		MRs:      []DashboardItem{{Repo: "krz/gitbay", Number: 552, Title: "wire $PAGER", Author: "cmc"}},
		Builds: []DashboardBuild{
			{Repo: "krz/omaha-metro-blotter", Number: 83, Job: "daily-pull", Status: "failure", Ref: "main", CreatedAt: "2026-09-30T12:00:00Z"},
			{Repo: "krz/gitbay", Number: 1779, Job: "test", Status: "success", Ref: "main", CreatedAt: "2026-10-01T10:00:00Z"},
			{Repo: "krz/gitbay", Number: 1778, Job: "build", Status: "success", Ref: "main", CreatedAt: "2026-10-01T10:00:00Z"},
		},
		Server: &ServerOut{Commit: "135f300"},
	}
}

func TestDashboardScreen(t *testing.T) {
	c := screenCtx(110, false)
	c.User = store.User{Username: "cmc", IsAdmin: true}
	s := dashboardScreen(c, dashFixture(), nil)
	var titles []string
	for _, sec := range s.sections {
		if len(sec.rows) > 0 {
			titles = append(titles, sec.title)
		}
	}
	want := []string{"Assigned issues", "Open merge requests", "Failed builds"}
	if !slices.Equal(titles, want) {
		t.Errorf("sections = %q, want %q", titles, want)
	}
	for _, f := range s.fields {
		if f.label == "Server" || f.label == "Queues" {
			t.Errorf("admin block %q on the dashboard screen", f.label)
		}
	}
	var first []string
	for _, a := range s.actions {
		first = append(first, strings.Join(a.argv, " "))
	}
	if !slices.Contains(first, "issue show krz/skunky-art 33") || !slices.Contains(first, "build log krz/omaha-metro-blotter 83") {
		t.Errorf("actions = %q", first)
	}
	if !slices.Contains(first, "admin stats") {
		t.Errorf("admin missing admin stats: %q", first)
	}
	checkActions(t, s)
}

func TestDashboardScreenPassingBuildsCollapse(t *testing.T) {
	c := screenCtx(110, false)
	d := dashFixture()
	d.Builds = d.Builds[1:]
	s := dashboardScreen(c, d, nil)
	for _, sec := range s.sections {
		if sec.title == "Failed builds" && len(sec.rows) > 0 {
			t.Error("failed builds section with no failures")
		}
	}
	if !strings.Contains(renderString(c, s), "2 builds passed") {
		t.Errorf("no passing-build summary:\n%s", renderString(c, s))
	}
}

Expected on run: undefined: dashboardScreen.

  • Step 3: Implement
// dashboardScreen is dashboard at a terminal: what waits on the viewer
// first, then their open merge requests, failed builds, a few lines of
// activity and the pinned repositories. The operator's queues are
// admin stats'; a mirror or queue failure shows as one header line.
func dashboardScreen(c *Ctx, d DashboardOut, lines []FeedLine) screen {
	var s screen
	s.fields = append(s.fields, field{"User", []cell{cText(c.User.Username)}}, field{"Instance", []cell{cText(c.Cfg.SiteHost())}})
	if q := d.Queues; q != nil {
		if bad := q.Webhooks.Failed + q.Mail.Failed + q.Push.Failed + q.Mirrors.Errors + q.Deps.Errors; bad > 0 {
			s.fields = append(s.fields, field{"Problems", []cell{cGlyph("failed"), cText(fmt.Sprintf("%d failing in the background", bad))}})
		}
	}
	if d.Unread > 0 {
		s.fields = append(s.fields, field{"Inbox", []cell{cYou(), cText(fmt.Sprintf("%d unread", d.Unread))}})
	}

	item := func(it DashboardItem, marker, page string, you bool) row {
		lead := cell{kind: kindGlyph}
		if you {
			lead = cYou()
		}
		ref := cLink(fmt.Sprintf("%s%s%d", it.Repo, marker, it.Number), c.siteURL(it.Repo, page, strconv.FormatInt(it.Number, 10)))
		return rowOf(ref, lead, cFlex(it.Title), cMeta(it.Author))
	}
	reviews := section{title: "Review requested", n: len(d.Reviews)}
	for _, it := range d.Reviews {
		reviews.rows = append(reviews.rows, item(it, "!", "mrs", true))
	}
	assigned := section{title: "Assigned issues", n: len(d.Assigned)}
	for _, it := range d.Assigned {
		assigned.rows = append(assigned.rows, item(it, "#", "issues", true))
	}
	mrs := section{title: "Open merge requests", n: len(d.MRs)}
	for _, it := range d.MRs {
		mrs.rows = append(mrs.rows, item(it, "!", "mrs", false))
	}

	failed := section{title: "Failed builds"}
	passed := 0
	seen := map[string]bool{}
	var firstFailed *DashboardBuild
	for i, b := range d.Builds {
		key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref
		if seen[key] {
			continue
		}
		seen[key] = true
		switch b.Status {
		case "success":
			passed++
		case "failure", "failed", "error":
			failed.n++
			failed.rows = append(failed.rows, rowOf(cLink(strconv.FormatInt(b.Number, 10), c.siteURL(b.Repo, "builds", strconv.FormatInt(b.Number, 10))),
				cGlyph(b.Status), cFlex(b.Job+"  "+b.Ref), cMeta(b.Repo, relAge(b.CreatedAt, termNow()))))
			if firstFailed == nil {
				firstFailed = &d.Builds[i]
			}
		}
	}

	activity := section{title: "Recent activity", n: len(lines), more: []string{"feed"}}
	for _, l := range lines[:min(len(lines), 5)] {
		activity.rows = append(activity.rows, rowOf(l.termCells(c)...))
	}
	if passed > 0 {
		activity.rows = append(activity.rows, rowOf(cMeta(fmt.Sprintf("%d builds passed", passed))))
	}

	pinned := section{title: "Pinned", n: len(d.Pinned)}
	if len(d.Pinned) > 0 {
		var paths []string
		for _, p := range d.Pinned {
			paths = append(paths, p.Path)
		}
		pinned.rows = []row{rowOf(cMeta(strings.Join(paths, "  ")))}
	}
	s.sections = []section{reviews, assigned, mrs, failed, activity, pinned}

	if len(d.Reviews) > 0 {
		it := d.Reviews[0]
		s.actions = append(s.actions, action{"Next", []string{"mr", "show", it.Repo, strconv.FormatInt(it.Number, 10)}})
	}
	if len(d.Assigned) > 0 {
		it := d.Assigned[0]
		s.actions = append(s.actions, action{"Next", []string{"issue", "show", it.Repo, strconv.FormatInt(it.Number, 10)}})
	}
	if firstFailed != nil {
		s.actions = append(s.actions, action{"Next", []string{"build", "log", firstFailed.Repo, strconv.FormatInt(firstFailed.Number, 10)}})
	}
	s.actions = append(s.actions, action{"More", []string{"feed"}})
	if c.User.IsAdmin {
		s.actions = append(s.actions, action{"Instance", []string{"admin", "stats"}})
	}
	return s
}

Notes:

  • 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.
  • 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.
  • 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.
  • Cfg.SiteHost() exists (repo.go uses it).

In 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.

  • Step 4: Run tests

Run: go test ./internal/control/ -run 'TestDashboard' Expected: PASS, pin unchanged.

  • Step 5: Verify, commit, MR
go build ./... && go vet ./... && go test ./internal/control/ ./internal/gitutil/ ./cmd/gitbay/
git add internal/control/dashboard.go internal/control/dashscreen_test.go internal/control/testdata/plain/dashboard.txt CHANGELOG.org
git commit -m "control: dashboard as a screen

Ref #318"
git push -u origin cli-views-2b
gitbay mr create --source cli-views-2b --target main --title "control: list and dashboard screens (stage 2b of CLI views)"

Stage 3

Written as its own plan after stage 2b merges, from the screen API as it stands then. Its scope: every remaining c.table and c.view caller (start with profile, release, milestone, org, label), the header lines of mr diff, repo diff, repo commit and build log, and removing view's terminal branches.

Self-review notes

  • Spec coverage: visual rules → tasks 1–4; model and emitView → 3, 5; here= → 4, 6; paging → 5; resolve test → 7; plain pinning → 7 and the first step of 10–15; screens → 10–15; wiki → 8; stage 3 deferred to its own plan, as the spec's stages allow.
  • Two places in tasks 10 and 15 are marked "decide on the rendered output" (the Files count, the activity count). They are presentation choices with both options spelled out, not missing work.