docs/plans/2026-10-01-cli-views.md
2553 lines · 84575 bytes
24 symbols in this file
CLI views implementation plan (stages 1 and 2)Global ConstraintsCorrections to the spec found while planningFile structureStage 1 (one MR, Ref #317)Task 1: Palette, glyph cells, dim refsTask 2: Header rows only where columns need namingTask 3: The screen model and rendererTask 4: Legend, `cmdline`, and `here=`Task 5: `emitView`, `emitPageView`, the next-page lineTask 6: The CLI sends `here=`Task 7: Test helpers for migrations — `checkActions` and `pinPlain`Task 8: Docs, changelog, MRStage 2a: show screens (one MR, Ref #318)Task 9: `gitutil.DiffNumstat`Task 10: `mr show` screenTask 11: `issue show` screenTask 12: `repo show` screenTask 13: `build show` screenStage 2b: lists and dashboard (one MR, Ref #318)Task 14: `mr list`, `issue list`, `build list` screensTask 15: `dashboard` screenStage 3Self-review notes
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
--jsonare byte-identical before and after every task. - All rendering is server-side in
internal/control;cmd/gitbayonly addshere=to--term. - Colour is never the only signal;
stripSGRof 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 #Nfor 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:
- There is no
browsecommand. The legend never offers one; the header's first field links to the page (OSC 8) where the terminal shows links, and aURL:field carries it otherwise. mr rebase,mr checkoutandrepo cloneare CLI-local (cmd/gitbay/local.go), not registry commands. The legend may name them; the resolve test accepts them from a fixed list.- The server cannot see the client's working directory. The CLI sends
here=<owner/name>in--termwhen it is inside a clone it can infer; legend commands drop an argument equal to it. - Actions carry no label: the legend prints group names and commands.
- 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. mr showcommits carry no signature glyph:CommitOuthas no signature state, and verifying each commit on every show is not worth the cost.repo logalready shows it.- Discussion rows carry their full body beneath them. A show screen that
printed only first lines would lose what
issue showprints 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, addglyph) - 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, newnumeric, newlines) - Test:
internal/control/table_test.go
Interfaces:
-
Produces:
func (t *table) lines() []string(terminal rows, padded and painted, header omitted);flushkeeps 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(afteremit) - 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(termValuesignature andrunSSHPaged) - 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* Unreleasedif 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(runMRShowat line 739; newmrShowScreen) - Test:
internal/control/mrscreen_test.go(new),internal/control/testdata/plain/mr-show.txt(new)
Interfaces:
-
Consumes:
screentypes,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 rebaseis 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;renderSectionprintstitle (n), so titleFiles +86 −12withn: 4renders asFiles +86 −12 (4). If that reads badly in the golden, add an optionalnote stringtosectiondrawn dim after the count, and use it here instead. Decide when you see the render; keep the test onn.
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(runIssueShowat line 381; newissueShowScreen) - 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(runRepoShowat line 376,repoGlance, newrepoShowScreen) - 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 localtype out structinrunRepoShowto package level asrepoShowOut(andmirrorOutasrepoMirrorOut) 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(runBuildShowat line 240, newbuildShowScreen) - 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(mrListTermat line 711 and its caller),issue.go(runIssueListterminal 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(runDashboardfrom line 140; deleteneedsYouonce 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 typeFeedLinesreturns (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: 0and one row, so it renders asRecent activity (0)above the summary. If that reads wrong, title the summary row's sectionBuildsinstead. Decide on the rendered output. - Saved query sections (
d.Queries) become one section each, titled with the query name andn: q.Count, rows asitemwithout a lead,more: []string{"query", "show", q.Name}(check the real subcommand withssh git@gitbay.org help query). Insert them aftermrs. Cfg.SiteHost()exists (repo.gouses 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.