Commit 9a566ebe6f
Verified · cmc
docs/specs/2026-09-23-cli-output-refresh-design.md added +283
| @@ -0,0 +1,283 @@ | ||
| 1 | # CLI output refresh | |
| 2 | ||
| 3 | Ref #254. Terminal rendering for tables, `show` views and help, with | |
| 4 | piped output unchanged in shape, plus the fixes from the 2026-09-23 CLI | |
| 5 | audit. | |
| 6 | ||
| 7 | ## Problem | |
| 8 | ||
| 9 | The plain output has had no design pass since v1.22.0. | |
| 10 | ||
| 11 | - Tables have no header, no colour and no width limit. The CLI pads | |
| 12 | tabs with `tabwriter` for the verbs in `listVerbs` | |
| 13 | (`cmd/gitbay/ssh.go`) and nothing else. | |
| 14 | - `show` views print stored markup verbatim: event lines read | |
| 15 | `referenced in commit [6c4d1e1454](/krz/gitbay/commit/…) by [cmc](/cmc)`, | |
| 16 | and timestamps are whatever string the row holds | |
| 17 | (`2026-09-23T23:26:00.570Z`). The web prints `2026-09-23 23:26 UTC`. | |
| 18 | - Root help carries usage in the summary column. Noun help is two | |
| 19 | lines per verb with the full usage. Verb help is the same two lines; | |
| 20 | no flag has a description, and no command has an example. | |
| 21 | - `pass()` in `cmd/gitbay/main.go` holds a second copy of each usage | |
| 22 | string, which drifts (`build list` still reads | |
| 23 | `recent builds: <owner/name>`). | |
| 24 | ||
| 25 | ## Decision | |
| 26 | ||
| 27 | The server renders. The CLI tells it the terminal's width and whether | |
| 28 | colour is wanted; the command's plain formatter chooses between | |
| 29 | terminal and plain rendering. The CLI adds a pager for long views and | |
| 30 | the grouped root help, and nothing else. | |
| 31 | ||
| 32 | Rejected: | |
| 33 | ||
| 34 | - The CLI rendering from `--json`. A second renderer per command in | |
| 35 | `cmd/gitbay`, which drifts from the server's, and stock ssh never | |
| 36 | sees the result. | |
| 37 | - A structured view format on the wire (typed columns, a document | |
| 38 | tree) styled by the CLI. A new protocol between two programs that | |
| 39 | ship together, for no visible gain over rendering on the server. | |
| 40 | ||
| 41 | ## Transport | |
| 42 | ||
| 43 | The CLI passes `-o SetEnv=GITBAY_TERM=<cols>[,color]` when stdout is a | |
| 44 | terminal. `color` is left out when `NO_COLOR` is set and non-empty, | |
| 45 | `TERM` is `dumb`, or `--no-color` is given (stripped by the CLI before | |
| 46 | dispatch). Piped stdout sends nothing. | |
| 47 | ||
| 48 | sshd accepts an `env` request named `GITBAY_TERM` and ignores every | |
| 49 | other name, as today. `runExec` parses the value into `Ctx.Term`: | |
| 50 | ||
| 51 | ```go | |
| 52 | type Term struct { | |
| 53 | Cols int // 0: plain output | |
| 54 | Color bool | |
| 55 | } | |
| 56 | ``` | |
| 57 | ||
| 58 | A missing or malformed value, or `Cols` under 40, is the zero value. | |
| 59 | The HTTP surfaces never set it. Under the system-sshd forced command | |
| 60 | (`gitbayd shell`) the variable arrives only if the operator adds | |
| 61 | `AcceptEnv GITBAY_TERM`; without it output is plain. The Admin wiki | |
| 62 | page says so. | |
| 63 | ||
| 64 | `SetEnv` needs OpenSSH 7.8. The CLI shares one connection through | |
| 65 | ControlMaster; the first task verifies that a multiplexed session | |
| 66 | carries `SetEnv`. If it does not, the CLI sends a leading | |
| 67 | `--term=<cols>[,color]` argument instead, and `Dispatch` strips it | |
| 68 | wherever it appears, as it strips `--json`. | |
| 69 | ||
| 70 | Stock ssh users opt in with `ssh -o SetEnv=GITBAY_TERM=120,color`. | |
| 71 | ||
| 72 | `alignColumns`, `listVerbs` and the `tabwriter` in `runSSH` are | |
| 73 | removed. | |
| 74 | ||
| 75 | ## Tables | |
| 76 | ||
| 77 | `internal/control/table.go`: | |
| 78 | ||
| 79 | ```go | |
| 80 | t := c.table("#", "STATE", "TITLE", "AUTHOR", "UPDATED") | |
| 81 | for _, is := range issues { | |
| 82 | t.row(ref("#", is.Number), state(is.State), text(is.Title), text(is.Author), age(is.UpdatedAt)) | |
| 83 | } | |
| 84 | t.flush() | |
| 85 | ``` | |
| 86 | ||
| 87 | Cells are typed: `ref`, `state`, `text`, `age`, `num`. The first | |
| 88 | `text` column is the flexible one. | |
| 89 | ||
| 90 | Plain (`Term.Cols == 0`): one row per item, cells joined by tabs, no | |
| 91 | header, `age` as RFC3339 to the second in UTC (`2026-09-23T23:26:00Z`). | |
| 92 | This is the current shape; only the timestamp format changes. | |
| 93 | ||
| 94 | Terminal: | |
| 95 | ||
| 96 | - Header row in capitals, dim when `Color`. | |
| 97 | - Columns padded with two spaces between them. | |
| 98 | - Width: when a row exceeds `Cols`, the flexible column is cut with | |
| 99 | `…`, down to 8 cells. If that is not enough, the other `text` | |
| 100 | columns are cut from the right. `ref`, `state`, `age` and `num` | |
| 101 | are never cut. Widths are measured in display cells | |
| 102 | (`golang.org/x/text/width`), not bytes. | |
| 103 | - `age` is relative: `just now` under a minute, then `5m ago`, | |
| 104 | `2h ago`, `3d ago` under 14 days, `3w ago` under 8 weeks, then | |
| 105 | `2006-01-02`. | |
| 106 | - `state` colour, ANSI 16-colour so the terminal's theme sets the | |
| 107 | shade, matching the web's state tokens: green (`--ok`) for `open`, | |
| 108 | `success`, `approved`; magenta (`--done`) for `merged`; red (`--bad`) | |
| 109 | for `failed`, `error`, `changes requested`; dim (`--neutral`) for | |
| 110 | `closed`, `draft`, `pending`, `canceled`. Anything else is | |
| 111 | uncoloured. | |
| 112 | ||
| 113 | An empty table prints `nothing to list` on stderr, as `emit` does now. | |
| 114 | ||
| 115 | `emitPage` in terminal mode prints the next cursor on stderr as | |
| 116 | `more: gitbay <path> <args> --cursor <c>`. Plain mode keeps the | |
| 117 | `next\t<c>` row. | |
| 118 | ||
| 119 | Every list command moves to `table`: each `emit`/`emitPage` whose | |
| 120 | plain formatter prints one row per item. Mutations and single-value | |
| 121 | reads keep their one line. | |
| 122 | ||
| 123 | ## Show views | |
| 124 | ||
| 125 | `internal/termtext` renders markdown (goldmark) and org (go-org) syntax | |
| 126 | trees to terminal text at a given width: | |
| 127 | ||
| 128 | - Paragraphs wrapped at `Cols - 2`, indented two spaces. | |
| 129 | - Headings bold; list items with `•` or their number, continuation | |
| 130 | lines hanging. | |
| 131 | - Code blocks indented four spaces, not wrapped, highlighted with | |
| 132 | chroma's `terminal16` formatter when `Color`. | |
| 133 | - Emphasis bold or underlined when `Color`, plain text otherwise. | |
| 134 | - Links as their text, followed by ` (<url>)` when the URL differs | |
| 135 | from the text. Relative forge links are made absolute from | |
| 136 | `server.site_url`. | |
| 137 | - Images as `[image: <alt>]`. | |
| 138 | ||
| 139 | The same package has a plain mode: no ANSI, no wrapping, links | |
| 140 | reduced as above. | |
| 141 | ||
| 142 | A `view` helper in `internal/control` lays out every `show`: | |
| 143 | ||
| 144 | ``` | |
| 145 | #253 Recorded CLI walkthrough on the landing page closed | |
| 146 | ||
| 147 | author cmc, 2026-09-22 18:04 UTC | |
| 148 | closed 2026-09-23 23:26 UTC by cmc in fc2380f3f0 | |
| 149 | labels docs, web | |
| 150 | milestone v1.35.0 | |
| 151 | url https://gitbay.org/krz/gitbay/issues/253 | |
| 152 | ||
| 153 | <rendered body> | |
| 154 | ||
| 155 | · cmc referenced this in 6c4d1e1454 2026-09-23 23:26 UTC | |
| 156 | · cmc closed this in fc2380f3f0 2026-09-23 23:26 UTC | |
| 157 | ||
| 158 | ── cmc, 2026-09-23 23:40 UTC ────────────────────────────────────── | |
| 159 | <rendered comment> | |
| 160 | ``` | |
| 161 | ||
| 162 | Title bold and state coloured when `Color`; events dim. Timestamps in | |
| 163 | views use the web's format, `2006-01-02 15:04 UTC`. Plain mode prints | |
| 164 | the same lines without colour or wrapping, with RFC3339 timestamps. | |
| 165 | Commands: `issue show`, `mr show`, `build show`, `release show`, | |
| 166 | `snippet show`, `repo show`, `milestone show`, `org show`, | |
| 167 | `profile show`, and any other `show` whose output has a body or more | |
| 168 | than one field. | |
| 169 | ||
| 170 | ### Pager | |
| 171 | ||
| 172 | When stdout is a terminal, the CLI runs `show`, `diff` and `log` | |
| 173 | verbs through a pager: `$GITBAY_PAGER`, else `$PAGER`, else | |
| 174 | `less -FRX`. `-F` exits when the output fits one screen. An empty | |
| 175 | `GITBAY_PAGER` disables it. `build log --follow` is never paged. The | |
| 176 | pager's exit does not change the command's exit code. | |
| 177 | ||
| 178 | ## Help | |
| 179 | ||
| 180 | `Command` gains: | |
| 181 | ||
| 182 | ```go | |
| 183 | type Flag struct { | |
| 184 | Name string // "--state" | |
| 185 | Arg string // "open|closed|all", empty for a switch | |
| 186 | Desc string // "which issues" | |
| 187 | Default string // "open", empty for none | |
| 188 | } | |
| 189 | ||
| 190 | Flags []Flag | |
| 191 | Examples []string // full argv after the program, owner/name explicit | |
| 192 | ``` | |
| 193 | ||
| 194 | `help --json` adds `flags` and `examples` to each entry. | |
| 195 | ||
| 196 | Server `help` with a prefix, in terminal mode: | |
| 197 | ||
| 198 | - Noun (a prefix matching more than one command): the noun's summary, | |
| 199 | `USAGE gitbay <noun> <verb> [<owner/name>] ...`, then `READ` and | |
| 200 | `WRITE` sections from `ReadOnly`, one line per verb with its | |
| 201 | summary, then `gitbay <noun> <verb> --help for flags.` | |
| 202 | - Verb (a prefix matching one command): summary, `USAGE`, `FLAGS` | |
| 203 | one per line with description and `(default …)`, `--json` last, | |
| 204 | then `EXAMPLES`. | |
| 205 | ||
| 206 | Examples print with a `gitbay ` prefix in terminal mode and | |
| 207 | `ssh <ssh host> ` in plain mode; both are valid because each example | |
| 208 | names its repository. | |
| 209 | ||
| 210 | Plain mode without a prefix stays one line per command. With a prefix, | |
| 211 | plain mode prints the same sections as terminal mode without colour. | |
| 212 | ||
| 213 | The CLI's noun and verb `--help` already go to the server. `pass()` | |
| 214 | drops its short text; the cobra command takes its one-line summary | |
| 215 | from a table generated from the registry (`go generate`, checked by a | |
| 216 | test that the file is current), so root and completion text never | |
| 217 | drift. | |
| 218 | ||
| 219 | Root help stays local to the CLI and is grouped: | |
| 220 | ||
| 221 | ``` | |
| 222 | WORK issue mr build release milestone label search | |
| 223 | REPOSITORIES repo wiki status webhook init | |
| 224 | YOU dashboard feed notifications auth profile snippet web | |
| 225 | INSTANCE org explore register migrate remote admin audit | |
| 226 | ``` | |
| 227 | ||
| 228 | one noun per line with its summary. A test fails when a top-level | |
| 229 | command is in no section or two. | |
| 230 | ||
| 231 | Registry test: every `--flag` token in `Usage` has a `Flags` entry | |
| 232 | and every `Flags` entry appears in `Usage`; every command has a | |
| 233 | non-empty `Desc` per flag and at least one example; every example | |
| 234 | resolves through `Lookup` to its own command. | |
| 235 | ||
| 236 | ## Audit fixes | |
| 237 | ||
| 238 | - `release list` takes `--limit`/`--cursor`; the title column is | |
| 239 | empty when the title equals the tag. | |
| 240 | - `notifications device add` prints `registered device <n>`. | |
| 241 | - `build log --follow` giving up on a queued build prints | |
| 242 | `build <n> still queued after 10m; run build log again to keep | |
| 243 | watching` and exits 1, unchanged. | |
| 244 | - Every missing positional goes through `usageWith` (`snippet show`, | |
| 245 | `org label list`, and any other found by the test below). | |
| 246 | - `dashboard` prints `none` under an empty section. | |
| 247 | - The stale `build list` help goes with the `pass()` short text. | |
| 248 | ||
| 249 | ## Rules | |
| 250 | ||
| 251 | The Users wiki "Output rules" gains an "At a terminal" section: | |
| 252 | headers, colour, width, relative ages, the pager, `GITBAY_TERM`, | |
| 253 | `NO_COLOR`. The piped rules stand, with timestamps stated as RFC3339 | |
| 254 | to the second. | |
| 255 | ||
| 256 | ## Testing | |
| 257 | ||
| 258 | - `table`: plain rows byte-identical to the current formatter for a | |
| 259 | fixture per cell type; terminal golden files at 60 and 120 columns, | |
| 260 | with and without colour; wide-character titles. | |
| 261 | - `termtext`: golden files for markdown and org fixtures covering | |
| 262 | every node type above, at 60 columns, plain and colour. | |
| 263 | - `Term` parsing: valid, malformed, under 40 columns, absent. | |
| 264 | - e2e: every `ReadOnly` command in `readArgs` | |
| 265 | (`e2e/readonly_test.go`) run twice through ssh: plain, asserting no | |
| 266 | `\x1b` in stdout; with `GITBAY_TERM=60,color`, asserting no line | |
| 267 | wider than 60 display cells after stripping ANSI, code blocks | |
| 268 | excepted. | |
| 269 | - e2e: a missing positional on every command exits 2 with the | |
| 270 | registered usage on stderr. | |
| 271 | - CLI: `SetEnv` sent only when stdout is a terminal; `NO_COLOR`, | |
| 272 | `TERM=dumb` and `--no-color` drop `color`; pager selection order. | |
| 273 | ||
| 274 | ## Delivery | |
| 275 | ||
| 276 | Five MRs, each shippable alone: | |
| 277 | ||
| 278 | 1. Audit fixes. | |
| 279 | 2. Transport, `Term`, `table`, and every list command. | |
| 280 | 3. `termtext`, `view`, every show command, the pager. | |
| 281 | 4. `Flags`/`Examples` for every command, help layouts, the generated | |
| 282 | summary table, grouped root help. | |
| 283 | 5. Users and Admin wiki pages, CHANGELOG. | |