# 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=` 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`: ```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`: ```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`: ```go // 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: ```go const ( kindText cellKind = iota kindFlex kindRef kindState kindAge kindNum kindSize kindSwatch kindGlyph kindMeta ) ``` ```go // 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: ```go 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: ```go 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** ```bash 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** ```go 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: ```go 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** ```bash 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: ```go 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`: ```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`** ```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: ```go 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** ```bash 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): ```go {"120,color,here=krz/gitbay", Term{Cols: 120, Color: true, Here: "krz/gitbay"}}, {"120,here=", Term{Cols: 120}}, ``` In `screen_test.go`: ```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`: ```go // 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`: ```go if v, ok := strings.CutPrefix(opt, "here="); ok { t.Here = v continue } ``` In `screen.go`, replace the stubs: ```go // 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** ```bash 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: ```go 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** ```go 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: ```go 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`: ```go // 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: ```go // 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: ```go fmt.Fprintln(c.Stderr, c.Term.paint(sgrBold, "Next page")+" "+c.Term.paint(sgrBlue, c.cmdline(c.nextArgv(next)))) ``` Then add: ```go // 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** ```bash 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`: ```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: ```go 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`: ```go 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** ```bash 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`: ```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`: ```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`: ```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, "