Commit 791af1d907

791af1d907607470c083d0bace2d8b3844de74aa

parent: c08291dd68

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-01 20:53 UTC

docs: CLI terminal output spec

Findings from a sweep of 51 read-only commands at a colour terminal,
and the four phases filed as #312-#315.

Ref #312

Layout: unified · split

docs/specs/2026-10-01-cli-terminal-output-design.md added +189
@@ -0,0 +1,189 @@
1# CLI terminal output
2
3Status: accepted, 2026-10-01. Findings from a sweep of 51 read-only commands
4against gitbay.org at a 110×50 colour terminal (`TERM=xterm-256color`),
5plus error paths and piped output.
6
7## How output works today
8
9The CLI sends `--term=<cols>[,color]` as the first server argument when
10stdout is a terminal. The server renders everything: `table` in
11`internal/control/table.go` (header, padding, fit-to-width, relative
12ages, colour on `kindState` cells), `view` in `view.go` (title line,
13aligned fields, rendered body, events, comments), helpers in `term.go`.
14Piped output is tab-separated rows with no header; `--json` is the
15contract. Both are correct and stay unchanged.
16
17The terminal mode exists but is thin. Colour applies only to state cells
18whose word appears in `stateColor`'s fixed list, and to dim headers and
19bold titles. Everything else renders as uncoloured text.
20
21## Constraints
22
23- Plain and `--json` output are byte-identical before and after, except
24 for the bug fixes marked **(plain)** below.
25- All rendering stays server-side in `internal/control`, so stock
26 `ssh git@gitbay.org --term=120,color repo list` gets the same result
27 as the CLI. No formatting in `cmd/gitbay`.
28- Colour is never the only signal: every coloured cell keeps its word.
29 `NO_COLOR`, `TERM=dumb`, `--no-color` keep working.
30- Colour roles follow the stylesheet: green ok, magenta done, red bad,
31 dim neutral, yellow for what wants the viewer (the web's orange),
32 blue/cyan for refs (the web's accent, "what you can act on").
33
34## Findings
35
36### Renderer-wide
37
38| # | Finding | Evidence |
39|---|---|---|
40| R1 | Only words in `stateColor`'s list are coloured. `public`/`private`, `verified`/`unsigned`, `full`/`runner` scopes, `admin`, `[archived]`, `primary` print plain. | `repo list`, `repo log`, `auth keys list`, `admin user list` |
41| R2 | `kindRef` exists but is never painted; refs (`krz/gitbay#12`, `!546`, SHAs, branch names) look like prose. | every list |
42| R3 | Future timestamps: `relAge` clamps negative durations to zero. Session expiry renders "until just now". | `web sessions list` |
43| R4 | Some timestamp columns are `cText`, so they stay RFC3339 at a terminal. | `auth token list` EXPIRES |
44| R5 | Durations in raw seconds. | `admin runners`: `wait avg 3223s`, `wait max 12886s` |
45| R6 | Sizes in raw bytes where `admin stats` already humanizes. | `repo tree`, `release show` assets (`3177346`) |
46| R7 | An optional trailing cell (`[archived]`, `primary`) adds an unnamed column: the header and every row get trailing whitespace, and the header has a blank title. | `repo list`, `auth email list` |
47| R8 | A flex column that is blank on most rows still takes its maximum width. | `release list`: 90-cell TITLE column, blank on every visible row |
48| R9 | Diffs are uncoloured. | `repo commit`, `mr diff`, `repo diff`, `mr range-diff` |
49| R10 | Errors print as bare stderr lines with no visual distinction; an unknown flag gets no suggestion; list-command usage is a single 200-character line. | `issue list --stat open` |
50| R11 | Section headings are inconsistent: `waiting on your review:` (lowercase, colon) on the dashboard; `link`/`org`/`repo` (singular) on `profile show`; `mirror` on `repo show`. The dashboard prints `none` under empty sections; other views skip them. | `dashboard`, `profile show`, `repo show` |
51
52### Per command
53
54| Command | Finding |
55|---|---|
56| `dashboard` | Empty sections ("waiting on your review: none") come first. No summary of what needs the viewer. A failing build looks the same as the other rows apart from its state word. Recent activity repeats `feed` (19 rows). |
57| `mr list` | No draft marker, checks result, review state, mergeability or age, so the open-MR list cannot answer "what do I do next". |
58| `issue list` | No labels, assignee, comment count or updated age. |
59| `repo show` | The description renders as a bold title, and `public` has no colour. No clone URL, open issue/MR counts, latest release or last build. |
60| `repo log` | Author as `(Name <email>)` takes a third of the width. No age column. Signature state is uncoloured, so an unsigned commit does not stand out. |
61| `repo refs` | **(plain)** Tags sort lexically (`v1.10.0` before `v1.2.0`), oldest first; `gitutil.SortVersions` already does this for the web. The default branch is not marked. |
62| `repo tree` | The SHA column comes first and is rarely wanted. Directories are not distinguished except by `/` and `-`. |
63| `repo commit` | **(plain)** The subject prints twice: `message` is everything after the first blank line of the payload, which is the whole message. The signature state and checks are computed and included in the JSON but not printed. The date is RFC3339. |
64| `release list` | No date. `10 asset(s)`. See R8. |
65| `release show` | Byte sizes; full 64-hex SHA-256 per asset. |
66| `label list` | Colour shown as hex text. A label with no colour leaves the cell blank. |
67| `milestone list` | `due -` when there is no due date; progress as text. |
68| `build show` | No steps, failed step, or pointer to `build log`. No link to the MR it belongs to. |
69| `build log` | `$ step` lines are not distinguished from output. |
70| `feed`, dashboard activity | Event sentences are uncoloured: `build success` and `build failure` read the same at a glance. |
71| `audit` | DATA column is raw JSON. |
72| `admin runners` | Fingerprints truncated (`SHA256:b5HGR…`) while `auth keys list` and `repo runner list` print them in full. |
73| `auth keys list`, `web sessions list` | The key or session in use is not marked. |
74| `auth whoami` | Prints the username only; at a terminal it could add instance, key label and scope. |
75| `profile show` | The README heading repeats the tagline already on the title line. |
76| mutations | `created krz/gitbay#312` with no URL; the output rules allow a second line for something to copy. |
77
78Things that work and should stay: column fitting with `…`, relative
79ages, `more: gitbay … --cursor` on stderr, pager for show/diff/log,
80`nothing to list` on stderr, `mr show` checks sub-table, exit codes and
81the not-found/usage messages.
82
83## Plan
84
85One issue per phase, one MR each, merged in order.
86
87### Phase 1: renderer foundations (`term.go`, `table.go`, `view.go`)
88
891. Colour roles. Replace `stateColor`'s word switch with a role table
90 (`ok`, `done`, `bad`, `neutral`, `warn`, `ref`) and add the missing
91 words: visibility (`private` bad, `public` default), signature
92 states from `internal/sig` (`verified` ok, `unsigned` and
93 `signed_unknown_key` neutral, the other `signed_*` and
94 `bad_signature` bad), `archived`, `primary`,
95 `admin`, scopes. Add `sgrYellow`, `sgrCyan`.
962. Paint `kindRef` cells cyan (R2).
973. `relAge` handles the future: `in 5h`, `in 3w`, a date past 8 weeks
98 (R3). Convert remaining timestamp `cText` cells to `cAge` (R4).
994. New cell kinds `cSize(bytes)` and `cDur(seconds)`; terminal renders
100 `3.0 MiB` / `53m43s`, plain keeps the number (R5, R6).
1015. Optional trailing cells move into a named column or merge into the
102 state cell (`public, archived`); `join` never pads the last column
103 and trims trailing spaces (R7).
1046. A flex column blank on more than half its rows is capped at a third
105 of the terminal width (R8).
1067. One `section()` style: Title Case, bold, no colon, in every view;
107 empty sections are skipped everywhere (R11).
1088. Errors: at a terminal, `error:` prefix in red on stderr. Unknown
109 flags get `did you mean --state?` from the registered usage (edit
110 distance ≤ 2). Long usage wraps one alternative per line (R10).
111
1129. `ParseTerm` reads `<cols>[,opt]...` and ignores options it does not
113 know, so phase 4's capability tokens reach an instance that has
114 phase 1 without turning its output plain.
115
116Tests: `table_test.go` and `term_test.go` cases per kind and role;
117plain output stays covered by the existing plain-mode table tests,
118since every change above is gated on `Term.Cols`.
119
120### Phase 2: diffs and logs
121
1221. A `diffPaint` helper for unified diffs at a terminal: file headers
123 bold, `@@` cyan, `+` green, `-` red, the stat block's `+`/`-` bars
124 coloured. Used by `repo commit`, `repo diff`, `mr diff`,
125 `mr range-diff`.
1262. `repo commit` as a view: title line with short SHA, subject and
127 signature state; fields for author, date (`when`), signer, checks;
128 body; coloured diff. Fix the duplicated subject in plain output
129 **(plain)**.
1303. `build log`: `$ step` lines bold at a terminal; the failing step's
131 line red when the build failed.
132
133### Phase 3: action-oriented content
134
135Each item adds columns or fields only at a terminal unless noted; plain
136columns stay as they are.
137
1381. `dashboard`: a first line summarising what needs the viewer
139 (`2 need you: 1 review requested, 1 failing build`, yellow);
140 non-empty action sections first (review requested, assigned, your
141 failing builds), then open MRs/issues, pinned, builds; activity cut
142 to 8 rows with a pointer to `gitbay feed`.
1432. `mr list`: DRAFT marker in the state cell, CHECKS (`3/3`, red on a
144 failure), REVIEW (`approved`, `changes requested`,
145 `review requested` in yellow when it is the viewer), UPDATED age.
1463. `issue list`: LABELS (first two, `+n`), ASSIGNEE, comment count,
147 UPDATED age.
1484. `repo show`: description as a body line rather than the title;
149 fields for clone URL (ssh), open issues/MRs, latest release, last
150 default-branch build with state.
1515. `repo log`: AUTHOR as name only, an AGE column, signature coloured.
1526. `repo refs`: `SortVersions`, newest first, default branch marked
153 **(plain: order only)**.
1547. `repo tree`: NAME first, directories cyan, SIZE humanized, SHA last.
1558. `release list`: DATE column, `10 assets`; `release show`: sizes
156 humanized, SHA-256 cut to 12 at a terminal.
1579. `build show`: steps with state and duration, the failing step
158 highlighted, `gitbay build log <n>` hint on stderr, the MR it
159 belongs to.
16010. `feed`/activity: colour the outcome word and refs.
16111. `milestone list`: blank due instead of `due -`, progress as
162 `1/2` plus a percentage.
16312. `audit`: DATA rendered as `key=value` pairs at a terminal.
16413. `auth keys list`, `web sessions list`: mark the current key or
165 session with `*` (as `mr revisions` does).
16614. `auth whoami`: at a terminal, add instance, key label and scope.
16715. Mutations: a second line with the web URL for anything with a page
168 (issue, MR, release, repo, snippet, wiki page).
16916. `admin runners`: full fingerprints, consistent with the other lists.
170
171### Phase 4: terminal capabilities
172
1731. Capability tokens in `--term`: `truecolor` for label swatches
174 (`●` in the label's colour), `links` for OSC 8 hyperlinks on refs.
175 Relies on phase 1's `ParseTerm` change being deployed first.
1762. Glyphs for compact columns (`✓ ✗ ●`), words kept everywhere else.
177
178## Decisions (2026-10-01)
179
1801. Yellow (`warn`) marks only what needs the viewer's action. `private`
181 is red.
1822. R8: a mostly-blank flex column is capped.
1833. Phase 4 is in scope. Swatches go on when the client reports
184 `COLORTERM=truecolor` or `24bit`; links go on when the client's
185 terminal is known to support OSC 8 (`TERM_PROGRAM` in iTerm.app,
186 WezTerm, ghostty, vscode; `KITTY_WINDOW_ID`; `VTE_VERSION` >= 5000)
187 or `GITBAY_LINKS=1`, and off with `GITBAY_LINKS=0`.
1884. Per-row data for `mr list` / `issue list` comes from one batch
189 query per page in `internal/store`.