control: terminal rendering foundations !547

merged merged by cmc on 2026-10-01 20:59 UTC · krz/gitbay:cli-term-1 into main

22 files changed, +647 −86

Layout: unified · split

.gitbay/wiki/Users.org +20 −5
@@ -1002,22 +1002,37 @@ terminal, and follows these rules so every noun reads the same way.
1002 1002
1003** At a terminal 1003** At a terminal
1004 1004
1005The =gitbay= CLI sends a leading =--term=<cols>[,color]= argument on 1005The =gitbay= CLI sends a leading =--term=<cols>[,<option>]...= argument on
1006the SSH command line when stdout is a terminal (OpenSSH's multiplexed 1006the SSH command line when stdout is a terminal (OpenSSH's multiplexed
1007sessions, which the CLI uses, do not forward a session's =SetEnv=). 1007sessions, which the CLI uses, do not forward a session's =SetEnv=).
1008The argument goes first: the server reads =--term=<v>= only as the 1008The argument goes first: the server reads =--term=<v>= only as the
1009first argument, and ignores it over HTTP. The server then prints: 1009first argument, and ignores it over HTTP. =color= is the one option
1010the server acts on; it ignores options it does not know. The server
1011then prints:
1010 1012
1011- lists under a header, padded, fitted to the width (the title or 1013- lists under a header, padded, fitted to the width (the title or
1012 description column is cut with =…= first), states in colour, ages as 1014 description column is cut with =…= first; a title or description
1013 =2h ago=, and the next page as a command on stderr (piped output 1015 column blank on most rows is held to a third of the width), with no
1014 keeps a =next\t<cursor>= row instead); 1016 trailing whitespace, and the next page as a command on stderr (piped
1017 output keeps a =next\t<cursor>= row instead);
1018- colour by meaning: green for open, success, approved, verified; magenta
1019 for merged; red for failures, =private=, and bad or untrusted
1020 signatures; dim for closed, pending, archived, unsigned; yellow only
1021 for what waits on you (an unverified address); references (=#12=,
1022 =krz/gitbay=, SHAs) in cyan. A marker piped output keeps as its own
1023 trailing cell (=[archived]=, =primary=) joins the state at a terminal:
1024 =public, archived=;
1025- ages as =2h ago= and =in 5h=, sizes as =3.0 MiB=, durations as
1026 =53m43s=, where piped output keeps RFC3339, bytes and seconds;
1015- =show= views with a title line, aligned fields, the body rendered 1027- =show= views with a title line, aligned fields, the body rendered
1016 from markdown or org, one line per event, and comments under a rule; 1028 from markdown or org, one line per event, and comments under a rule;
1017 a sub-table (labels, revisions, and the like) carries a section label 1029 a sub-table (labels, revisions, and the like) carries a section label
1018 line in both terminal and piped output; timestamps as 1030 line in both terminal and piped output; timestamps as
1019 =2026-09-23 23:26 UTC=. =wiki show= piped prints the page source 1031 =2026-09-23 23:26 UTC=. =wiki show= piped prints the page source
1020 verbatim; the rendered page is terminal-only; 1032 verbatim; the rendered page is terminal-only;
1033- errors on stderr after a red =error:=, a mistyped flag with the flag
1034 it is closest to (=did you mean --state?=), and a usage line wrapped
1035 to the width between its bracketed groups, one alternative per line;
1021- help with flag descriptions, defaults, and examples; =gitbay --help= 1036- help with flag descriptions, defaults, and examples; =gitbay --help=
1022 groups commands under WORK, REPOSITORIES, YOU and INSTANCE; 1037 groups commands under WORK, REPOSITORIES, YOU and INSTANCE;
1023 =help --json= adds =flags= and =examples=. 1038 =help --json= adds =flags= and =examples=.
CHANGELOG.org +11
@@ -16,6 +16,17 @@ anything beyond "replace the binary and restart" is needed.
16 draft controls to its author as well as to writers, matching the 16 draft controls to its author as well as to writers, matching the
17 commands: an author without write access could do it over SSH but had 17 commands: an author without write access could do it over SSH but had
18 no button. (#311) 18 no button. (#311)
19- Terminal output reads at a glance (#312): colour by meaning across
20 every list and view (=private= red, signature states, references in
21 cyan, yellow only for what waits on you), future times as =in 5h=,
22 sizes and durations humanized, no unnamed trailing column or trailing
23 whitespace, a mostly blank title column held to a third of the width,
24 section headings in one style with empty dashboard sections left out,
25 and errors after a red =error:= with =did you mean= for a mistyped
26 flag and the usage wrapped. Piped and =--json= output are unchanged,
27 except that =profile show='s section labels are plural (=links:=,
28 =orgs:=, =members:=, =repos:=) and an unknown flag's message names the
29 nearest flag. The server ignores =--term= options it does not know.
19 30
20* v1.40.1 — 2026-09-29 31* v1.40.1 — 2026-09-29
21 32
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`.
internal/control/admin.go +5 −5
@@ -312,7 +312,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
312 } 312 }
313 cells := []cell{cRef(e.Address), cState(state)} 313 cells := []cell{cRef(e.Address), cState(state)}
314 if e.Primary { 314 if e.Primary {
315 cells = append(cells, cText("primary")) 315 cells = c.note(cells, 1, "primary", "primary")
316 } 316 }
317 te.row(cells...) 317 te.row(cells...)
318 } 318 }
@@ -442,9 +442,9 @@ func runAdminRepoList(c *Ctx, args []string) int {
442 return c.emitPage(p, ds, next, func(w io.Writer) { 442 return c.emitPage(p, ds, next, func(w io.Writer) {
443 tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH") 443 tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH")
444 for _, d := range ds { 444 for _, d := range ds {
445 cells := []cell{cRef(d.Path), cState(d.Visibility), cNum(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)} 445 cells := []cell{cRef(d.Path), cState(d.Visibility), cSize(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)}
446 if d.Archived { 446 if d.Archived {
447 cells = append(cells, cText("[archived]")) 447 cells = c.note(cells, 1, "[archived]", "archived")
448 } 448 }
449 tb.row(cells...) 449 tb.row(cells...)
450 } 450 }
@@ -579,8 +579,8 @@ func runAdminRunners(c *Ctx, args []string) int {
579 v.fields( 579 v.fields(
580 "pending", fmt.Sprintf("%d", queue.Pending), 580 "pending", fmt.Sprintf("%d", queue.Pending),
581 "claimed 24h", fmt.Sprintf("%d", queue.Claimed24h), 581 "claimed 24h", fmt.Sprintf("%d", queue.Claimed24h),
582 "wait avg", fmt.Sprintf("%ds", queue.ClaimWaitAvgS), 582 "wait avg", c.Term.dur(queue.ClaimWaitAvgS),
583 "wait max", fmt.Sprintf("%ds", queue.ClaimWaitMaxS), 583 "wait max", c.Term.dur(queue.ClaimWaitMaxS),
584 "reaped 24h", fmt.Sprintf("%d", queue.Reaped24h), 584 "reaped 24h", fmt.Sprintf("%d", queue.Reaped24h),
585 ) 585 )
586 if len(runners) > 0 { 586 if len(runners) > 0 {
internal/control/control.go +3
@@ -419,6 +419,9 @@ func (c *Ctx) fail(code int, format string, args ...any) int {
419 enc.SetEscapeHTML(false) 419 enc.SetEscapeHTML(false)
420 enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg}) 420 enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg})
421 } else { 421 } else {
422 if c.Term.Cols > 0 {
423 msg = c.Term.failure(msg)
424 }
422 fmt.Fprintln(c.Stderr, msg) 425 fmt.Fprintln(c.Stderr, msg)
423 } 426 }
424 return code 427 return code
internal/control/dashboard.go +21 −7
@@ -177,12 +177,25 @@ func runDashboard(c *Ctx, args []string) int {
177 } 177 }
178 178
179 return c.emit(d, func(w io.Writer) { 179 return c.emit(d, func(w io.Writer) {
180 section := func(title string, header []string, rows [][]cell) { 180 // At a terminal sections are separated by a blank line, and an
181 if c.Term.Cols > 0 { 181 // empty one is left out.
182 fmt.Fprintln(w, c.Term.paint(sgrBold, title)) 182 wrote := false
183 } else { 183 heading := func(title string) {
184 if c.Term.Cols == 0 {
184 fmt.Fprintln(w, title) 185 fmt.Fprintln(w, title)
186 return
187 }
188 if wrote {
189 fmt.Fprintln(w)
190 }
191 wrote = true
192 fmt.Fprintln(w, c.Term.heading(title))
193 }
194 section := func(title string, header []string, rows [][]cell) {
195 if c.Term.Cols > 0 && len(rows) == 0 {
196 return
185 } 197 }
198 heading(title)
186 if len(rows) == 0 { 199 if len(rows) == 0 {
187 fmt.Fprintln(w, " none") 200 fmt.Fprintln(w, " none")
188 return 201 return
@@ -238,7 +251,7 @@ func runDashboard(c *Ctx, args []string) int {
238 for i, p := range d.Pinned { 251 for i, p := range d.Pinned {
239 cells := []cell{cRef(p.Path), cState(p.Visibility), cFlex(p.Description)} 252 cells := []cell{cRef(p.Path), cState(p.Visibility), cFlex(p.Description)}
240 if p.Archived { 253 if p.Archived {
241 cells = append(cells, cText("[archived]")) 254 cells = c.note(cells, 1, "[archived]", "archived")
242 } 255 }
243 pinnedRows[i] = cells 256 pinnedRows[i] = cells
244 } 257 }
@@ -258,10 +271,11 @@ func runDashboard(c *Ctx, args []string) int {
258 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows) 271 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows)
259 272
260 if d.Server != nil { 273 if d.Server != nil {
261 fmt.Fprintf(w, "server:\n build %s\n", d.Server.Commit) 274 heading("server:")
275 fmt.Fprintf(w, " build %s\n", d.Server.Commit)
262 } 276 }
263 if q := d.Queues; q != nil { 277 if q := d.Queues; q != nil {
264 fmt.Fprintln(w, "queues:") 278 heading("queues:")
265 279
266 fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed) 280 fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed)
267 twh := c.table(w, "REPO", "URL", "ATTEMPTS", "ERROR") 281 twh := c.table(w, "REPO", "URL", "ATTEMPTS", "ERROR")
internal/control/deploykey.go +2 −2
@@ -84,7 +84,7 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
84 return c.emit(d, func(w io.Writer) { 84 return c.emit(d, func(w io.Writer) {
85 line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path()) 85 line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path())
86 if expires != nil { 86 if expires != nil {
87 line += ", expires " + expiresText(expires, time.Now()) 87 line += ", expires " + c.expiresText(expires, time.Now())
88 } 88 }
89 fmt.Fprintln(w, line) 89 fmt.Fprintln(w, line)
90 }) 90 })
@@ -123,7 +123,7 @@ func runDeployKeyList(c *Ctx, args []string) int {
123 tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES") 123 tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES")
124 for _, d := range ds { 124 for _, d := range ds {
125 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label), 125 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label),
126 cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now))) 126 cText(c.usedText(d.LastUsedAt)), cText(c.expiresText(d.ExpiresAt, now)))
127 } 127 }
128 tb.flush() 128 tb.flush()
129 }) 129 })
internal/control/flags.go +45
@@ -80,6 +80,9 @@ func parseFlags(args []string, spec flagSpec) (flags, error) {
80 } 80 }
81 i++ 81 i++
82 default: 82 default:
83 if near := nearestFlag(a, kind); near != "" {
84 return f, usage("unknown flag %q; did you mean %s?", a, near)
85 }
83 return f, usage("unknown flag %q", a) 86 return f, usage("unknown flag %q", a)
84 } 87 }
85 continue 88 continue
@@ -92,6 +95,48 @@ func parseFlags(args []string, spec flagSpec) (flags, error) {
92 return f, nil 95 return f, nil
93} 96}
94 97
98// nearestFlag is the known flag closest to an unknown one: the only
99// flag it is a prefix of, or else the only one within two edits.
100func nearestFlag(a string, known map[string]byte) string {
101 var prefixed, close []string
102 for n := range known {
103 if strings.HasPrefix(n, a) {
104 prefixed = append(prefixed, n)
105 }
106 if editDistance(a, n) <= 2 {
107 close = append(close, n)
108 }
109 }
110 switch {
111 case len(prefixed) == 1:
112 return prefixed[0]
113 case len(prefixed) == 0 && len(close) == 1:
114 return close[0]
115 }
116 return ""
117}
118
119// editDistance is the Levenshtein distance between two ASCII strings.
120func editDistance(a, b string) int {
121 prev := make([]int, len(b)+1)
122 cur := make([]int, len(b)+1)
123 for j := range prev {
124 prev[j] = j
125 }
126 for i := 1; i <= len(a); i++ {
127 cur[0] = i
128 for j := 1; j <= len(b); j++ {
129 cost := 1
130 if a[i-1] == b[j-1] {
131 cost = 0
132 }
133 cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
134 }
135 prev, cur = cur, prev
136 }
137 return prev[len(b)]
138}
139
95// parseArgs is parseFlags for the running command, with the usage line 140// parseArgs is parseFlags for the running command, with the usage line
96// printed the way a usage refusal prints it (cmdUsage): the program in 141// printed the way a usage refusal prints it (cmdUsage): the program in
97// front and the CLI's own path where it differs (#267). spec.Usage stays 142// front and the CLI's own path where it differs (#267). spec.Usage stays
internal/control/flags_test.go +16
@@ -31,3 +31,19 @@ func TestParseFlags(t *testing.T) {
31 t.Fatalf("-- handling: %v %+v", err, f) 31 t.Fatalf("-- handling: %v %+v", err, f)
32 } 32 }
33} 33}
34
35func TestNearestFlag(t *testing.T) {
36 known := map[string]byte{"--state": 'v', "--label": 'm', "--limit": 'v', "--json": 'b'}
37 cases := map[string]string{
38 "--stat": "--state",
39 "--lable": "--label",
40 "--l": "",
41 "--jsno": "--json",
42 "--nothing": "",
43 }
44 for in, want := range cases {
45 if got := nearestFlag(in, known); got != want {
46 t.Errorf("nearestFlag(%q) = %q, want %q", in, got, want)
47 }
48 }
49}
internal/control/identity.go +8 −5
@@ -102,7 +102,7 @@ func runKeysList(c *Ctx, args []string) int {
102 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES") 102 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES")
103 for _, d := range ds { 103 for _, d := range ds {
104 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label), 104 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label),
105 cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now))) 105 cText(c.usedText(d.LastUsedAt)), cText(c.expiresText(d.ExpiresAt, now)))
106 } 106 }
107 tb.flush() 107 tb.flush()
108 }) 108 })
@@ -138,13 +138,16 @@ func (c *Ctx) usedText(ts string) string {
138 return relAge(ts, termNow()) 138 return relAge(ts, termNow())
139} 139}
140 140
141// expiresText is a credential's expiry as an EXPIRES cell shows it. It 141// expiresText is a credential's expiry as an EXPIRES cell shows it:
142// is absolute at a terminal too: relAge reads only the past. 142// RFC3339 in plain output, relative at a terminal.
143func expiresText(t *time.Time, now time.Time) string { 143func (c *Ctx) expiresText(t *time.Time, now time.Time) string {
144 if t == nil { 144 if t == nil {
145 return "never" 145 return "never"
146 } 146 }
147 s := stamp(t.UTC().Format(time.RFC3339Nano)) 147 s := stamp(t.UTC().Format(time.RFC3339Nano))
148 if c.Term.Cols > 0 {
149 s = relAge(s, now)
150 }
148 if !t.After(now) { 151 if !t.After(now) {
149 return "expired " + s 152 return "expired " + s
150 } 153 }
@@ -204,7 +207,7 @@ func runKeysAdd(c *Ctx, args []string) int {
204 line += " " + d.Label 207 line += " " + d.Label
205 } 208 }
206 if d.ExpiresAt != nil { 209 if d.ExpiresAt != nil {
207 line += ", expires " + expiresText(d.ExpiresAt, time.Now()) 210 line += ", expires " + c.expiresText(d.ExpiresAt, time.Now())
208 } 211 }
209 fmt.Fprintln(w, line) 212 fmt.Fprintln(w, line)
210 }) 213 })
internal/control/profile.go +4 −4
@@ -265,7 +265,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
265 "activity", activity, 265 "activity", activity,
266 ) 266 )
267 if len(d.Links) > 0 { 267 if len(d.Links) > 0 {
268 v.section("link") 268 v.section("links")
269 tb := c.table(w, "LINK", "URL") 269 tb := c.table(w, "LINK", "URL")
270 for _, l := range d.Links { 270 for _, l := range d.Links {
271 tb.row(cText(l.Label), cFlex(l.URL)) 271 tb.row(cText(l.Label), cFlex(l.URL))
@@ -273,7 +273,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
273 tb.flush() 273 tb.flush()
274 } 274 }
275 if len(d.Orgs) > 0 { 275 if len(d.Orgs) > 0 {
276 v.section("org") 276 v.section("orgs")
277 tb := c.table(w, "ORG", "ROLE") 277 tb := c.table(w, "ORG", "ROLE")
278 for _, m := range d.Orgs { 278 for _, m := range d.Orgs {
279 tb.row(cRef(m.Name), cState(m.Role)) 279 tb.row(cRef(m.Name), cState(m.Role))
@@ -281,7 +281,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
281 tb.flush() 281 tb.flush()
282 } 282 }
283 if len(d.Members) > 0 { 283 if len(d.Members) > 0 {
284 v.section("member") 284 v.section("members")
285 tb := c.table(w, "MEMBER", "ROLE") 285 tb := c.table(w, "MEMBER", "ROLE")
286 for _, m := range d.Members { 286 for _, m := range d.Members {
287 tb.row(cRef(m.Name), cState(m.Role)) 287 tb.row(cRef(m.Name), cState(m.Role))
@@ -289,7 +289,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
289 tb.flush() 289 tb.flush()
290 } 290 }
291 if len(d.Repos) > 0 { 291 if len(d.Repos) > 0 {
292 v.section("repo") 292 v.section("repos")
293 tb := c.table(w, "REPO", "VISIBILITY", "DESCRIPTION") 293 tb := c.table(w, "REPO", "VISIBILITY", "DESCRIPTION")
294 for _, r := range d.Repos { 294 for _, r := range d.Repos {
295 tb.row(cRef(r.Path), cState(r.Visibility), cFlex(r.Description)) 295 tb.row(cRef(r.Path), cState(r.Visibility), cFlex(r.Description))
internal/control/read.go +5 −8
@@ -317,7 +317,11 @@ func runRepoTree(c *Ctx, args []string) int {
317 if e.Type == "tree" { 317 if e.Type == "tree" {
318 name += "/" 318 name += "/"
319 } 319 }
320 tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), cText(sizeCol(e)), cFlex(name)) 320 size := cText("-")
321 if e.Type != "tree" {
322 size = cSize(e.Size)
323 }
324 tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), size, cFlex(name))
321 } 325 }
322 tb.flush() 326 tb.flush()
323 }) 327 })
@@ -390,13 +394,6 @@ func runRepoReadme(c *Ctx, args []string) int {
390 }) 394 })
391} 395}
392 396
393func sizeCol(e entryOut) string {
394 if e.Type == "tree" {
395 return "-"
396 }
397 return fmt.Sprintf("%d", e.Size)
398}
399
400// readmeRank orders competing README files: richer renderers win. 397// readmeRank orders competing README files: richer renderers win.
401var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3} 398var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
402 399
internal/control/register.go +1 −1
@@ -82,7 +82,7 @@ func runEmailList(c *Ctx, args []string) int {
82 } 82 }
83 cells := []cell{cRef(d.Address), cState(state)} 83 cells := []cell{cRef(d.Address), cState(state)}
84 if d.Primary { 84 if d.Primary {
85 cells = append(cells, cText("primary")) 85 cells = c.note(cells, 1, "primary", "primary")
86 } 86 }
87 tb.row(cells...) 87 tb.row(cells...)
88 } 88 }
internal/control/release.go +1 −1
@@ -313,7 +313,7 @@ func runReleaseShow(c *Ctx, args []string) int {
313 v.section("assets") 313 v.section("assets")
314 tb := c.table(w, "NAME", "SIZE", "SHA256") 314 tb := c.table(w, "NAME", "SIZE", "SHA256")
315 for _, a := range d.Assets { 315 for _, a := range d.Assets {
316 tb.row(cRef(a.Name), cNum(a.Size), cFlex(a.SHA256)) 316 tb.row(cRef(a.Name), cSize(a.Size), cFlex(a.SHA256))
317 } 317 }
318 tb.flush() 318 tb.flush()
319 } 319 }
internal/control/repo.go +1 −1
@@ -365,7 +365,7 @@ func runRepoList(c *Ctx, args []string) int {
365 for _, d := range ds { 365 for _, d := range ds {
366 cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)} 366 cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)}
367 if d.Archived { 367 if d.Archived {
368 cells = append(cells, cText("[archived]")) 368 cells = c.note(cells, 1, "[archived]", "archived")
369 } 369 }
370 tb.row(cells...) 370 tb.row(cells...)
371 } 371 }
internal/control/table.go +50 −5
@@ -15,6 +15,7 @@ const (
15 kindState 15 kindState
16 kindAge 16 kindAge
17 kindNum 17 kindNum
18 kindSize
18) 19)
19 20
20// cell is one column of a table row. The kind decides colour, time 21// cell is one column of a table row. The kind decides colour, time
@@ -30,6 +31,7 @@ func cText(s string) cell { return cell{kindText, s} }
30func cFlex(s string) cell { return cell{kindFlex, s} } 31func cFlex(s string) cell { return cell{kindFlex, s} }
31func cAge(ts string) cell { return cell{kindAge, ts} } 32func cAge(ts string) cell { return cell{kindAge, ts} }
32func cNum(n int64) cell { return cell{kindNum, strconv.FormatInt(n, 10)} } 33func cNum(n int64) cell { return cell{kindNum, strconv.FormatInt(n, 10)} }
34func cSize(n int64) cell { return cell{kindSize, strconv.FormatInt(n, 10)} }
33 35
34// table is a list command's rows. Plain, each row is written as it 36// table is a list command's rows. Plain, each row is written as it
35// comes, tab-separated with no header. At a terminal rows are held 37// comes, tab-separated with no header. At a terminal rows are held
@@ -42,6 +44,18 @@ type table struct {
42 rows [][]cell 44 rows [][]cell
43} 45}
44 46
47// note adds a marker to a row: its own trailing cell in plain output,
48// as rows have always carried it, and joined to the state cell at
49// cells[at] at a terminal ("private, archived"), so the table has no
50// unnamed column.
51func (c *Ctx) note(cells []cell, at int, plain, word string) []cell {
52 if c.Term.Cols == 0 {
53 return append(cells, cText(plain))
54 }
55 cells[at].s += ", " + word
56 return cells
57}
58
45func (c *Ctx) table(w io.Writer, header ...string) *table { 59func (c *Ctx) table(w io.Writer, header ...string) *table {
46 return &table{term: c.Term, w: w, header: header} 60 return &table{term: c.Term, w: w, header: header}
47} 61}
@@ -62,8 +76,13 @@ func (t *table) row(cs ...cell) {
62 now := termNow() 76 now := termNow()
63 for i := range cs { 77 for i := range cs {
64 cs[i].s = termSafe(cs[i].s) 78 cs[i].s = termSafe(cs[i].s)
65 if cs[i].kind == kindAge { 79 switch cs[i].kind {
80 case kindAge:
66 cs[i].s = relAge(cs[i].s, now) 81 cs[i].s = relAge(cs[i].s, now)
82 case kindSize:
83 if n, err := strconv.ParseInt(cs[i].s, 10, 64); err == nil {
84 cs[i].s = humanBytes(n)
85 }
67 } 86 }
68 } 87 }
69 t.rows = append(t.rows, cs) 88 t.rows = append(t.rows, cs)
@@ -89,6 +108,7 @@ func (t *table) flush() {
89 widths[i] = max(widths[i], cells(r[i].s)) 108 widths[i] = max(widths[i], cells(r[i].s))
90 } 109 }
91 } 110 }
111 t.capSparse(widths)
92 t.fit(widths) 112 t.fit(widths)
93 113
94 var b strings.Builder 114 var b strings.Builder
@@ -98,7 +118,7 @@ func (t *table) flush() {
98 line[i] = clip(t.header[i], widths[i]) 118 line[i] = clip(t.header[i], widths[i])
99 } 119 }
100 } 120 }
101 b.WriteString(t.term.paint(sgrDim, t.join(line, widths)) + "\n") 121 b.WriteString(t.term.paint(sgrDim, strings.TrimRight(t.join(line, widths), " ")) + "\n")
102 for _, r := range t.rows { 122 for _, r := range t.rows {
103 for i := 0; i < n; i++ { 123 for i := 0; i < n; i++ {
104 s := "" 124 s := ""
@@ -107,11 +127,31 @@ func (t *table) flush() {
107 } 127 }
108 line[i] = s 128 line[i] = s
109 } 129 }
110 b.WriteString(t.joinRow(r, line, widths) + "\n") 130 b.WriteString(strings.TrimRight(t.joinRow(r, line, widths), " ") + "\n")
111 } 131 }
112 io.WriteString(t.w, b.String()) 132 io.WriteString(t.w, b.String())
113} 133}
114 134
135// capSparse narrows a flexible column that is blank on most rows to a
136// third of the terminal, so a few long values do not push every other
137// row's later columns to the right edge.
138func (t *table) capSparse(widths []int) {
139 for i := range widths {
140 filled, flex := 0, false
141 for _, r := range t.rows {
142 if i < len(r) && r[i].kind == kindFlex {
143 flex = true
144 if r[i].s != "" {
145 filled++
146 }
147 }
148 }
149 if flex && filled*2 < len(t.rows) {
150 widths[i] = min(widths[i], max(8, t.term.Cols/3))
151 }
152 }
153}
154
115// fit shrinks columns until a row fits the terminal: the flexible 155// fit shrinks columns until a row fits the terminal: the flexible
116// column first, down to 8 cells, then the other text columns from the 156// column first, down to 8 cells, then the other text columns from the
117// right, down to 8 each. 157// right, down to 8 each.
@@ -177,8 +217,13 @@ func (t *table) joinRow(r []cell, line []string, widths []int) string {
177 if i < len(line)-1 { 217 if i < len(line)-1 {
178 padding = strings.Repeat(" ", max(0, widths[i]-cells(s))) 218 padding = strings.Repeat(" ", max(0, widths[i]-cells(s)))
179 } 219 }
180 if i < len(r) && r[i].kind == kindState { 220 if i < len(r) {
181 s = t.term.paint(stateColor(s), s) 221 switch r[i].kind {
222 case kindState:
223 s = t.term.paintState(s)
224 case kindRef:
225 s = t.term.paint(sgrCyan, s)
226 }
182 } 227 }
183 b.WriteString(s + padding) 228 b.WriteString(s + padding)
184 } 229 }
internal/control/table_test.go +47
@@ -124,3 +124,50 @@ func TestTableEmptyPrintsNothing(t *testing.T) {
124 t.Errorf("empty table printed %q", b.String()) 124 t.Errorf("empty table printed %q", b.String())
125 } 125 }
126} 126}
127
128func TestTableNoTrailingSpace(t *testing.T) {
129 var b bytes.Buffer
130 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "ADDRESS", "STATE")
131 tb.row(cRef("a@example.com"), cState("verified"), cText("primary"))
132 tb.row(cRef("b@example.com"), cState("verified"))
133 tb.flush()
134 for _, line := range strings.Split(b.String(), "\n") {
135 if strings.HasSuffix(line, " ") {
136 t.Errorf("trailing space: %q", line)
137 }
138 }
139}
140
141// A flexible column blank on most rows is capped at a third of the
142// terminal.
143func TestTableCapsSparseFlex(t *testing.T) {
144 var b bytes.Buffer
145 tb := (&Ctx{Term: Term{Cols: 90}}).table(&b, "TAG", "TITLE", "ASSETS")
146 tb.row(cRef("v3"), cFlex(""), cText("2"))
147 tb.row(cRef("v2"), cFlex(""), cText("2"))
148 tb.row(cRef("v1"), cFlex(""), cText("2"))
149 tb.row(cRef("v0"), cFlex(strings.Repeat("x", 60)), cText("2"))
150 tb.flush()
151 lines := strings.Split(b.String(), "\n")
152 if at := strings.Index(lines[0], "ASSETS"); at != len("TAG ")+30+2 {
153 t.Errorf("ASSETS at %d:\n%s", at, b.String())
154 }
155}
156
157func TestTableSizeCells(t *testing.T) {
158 var plain, term bytes.Buffer
159 for _, c := range []struct {
160 ctx *Ctx
161 w *bytes.Buffer
162 }{{&Ctx{}, &plain}, {&Ctx{Term: Term{Cols: 80}}, &term}} {
163 tb := c.ctx.table(c.w, "NAME", "SIZE")
164 tb.row(cRef("a"), cSize(2048))
165 tb.flush()
166 }
167 if plain.String() != "a\t2048\n" {
168 t.Errorf("plain = %q", plain.String())
169 }
170 if term.String() != "NAME SIZE\na 2.0 KiB\n" {
171 t.Errorf("term = %q", term.String())
172 }
173}
internal/control/term.go +140 −20
@@ -19,21 +19,23 @@ type Term struct {
19 Color bool 19 Color bool
20} 20}
21 21
22// ParseTerm reads "<cols>[,color]". Anything else, or a width outside 22// ParseTerm reads "<cols>[,<option>]...". Options it does not know are
23// ignored, so a newer client's capabilities do not turn an older
24// server's output plain. A width that is not a number, or is outside
23// 40 to 1000, is plain output. 25// 40 to 1000, is plain output.
24func ParseTerm(v string) Term { 26func ParseTerm(v string) Term {
25 cols, opt, hasOpt := strings.Cut(v, ",") 27 parts := strings.Split(v, ",")
26 n, err := strconv.Atoi(cols) 28 n, err := strconv.Atoi(parts[0])
27 if err != nil || n < 40 || n > 1000 { 29 if err != nil || n < 40 || n > 1000 {
28 return Term{} 30 return Term{}
29 } 31 }
30 switch { 32 t := Term{Cols: n}
31 case !hasOpt: 33 for _, opt := range parts[1:] {
32 return Term{Cols: n} 34 if opt == "color" {
33 case opt == "color": 35 t.Color = true
34 return Term{Cols: n, Color: true} 36 }
35 } 37 }
36 return Term{} 38 return t
37} 39}
38 40
39const ( 41const (
@@ -42,7 +44,9 @@ const (
42 sgrDim = "\x1b[2m" 44 sgrDim = "\x1b[2m"
43 sgrRed = "\x1b[31m" 45 sgrRed = "\x1b[31m"
44 sgrGreen = "\x1b[32m" 46 sgrGreen = "\x1b[32m"
47 sgrYellow = "\x1b[33m"
45 sgrMagenta = "\x1b[35m" 48 sgrMagenta = "\x1b[35m"
49 sgrCyan = "\x1b[36m"
46) 50)
47 51
48// termSafe replaces the bytes a terminal would act on — ESC, the C0 52// termSafe replaces the bytes a terminal would act on — ESC, the C0
@@ -86,21 +90,107 @@ func (t Term) paint(sgr, s string) string {
86} 90}
87 91
88// stateColor maps a state word to the web's state tokens: --ok green, 92// stateColor maps a state word to the web's state tokens: --ok green,
89// --done magenta, --bad red, --neutral dim. 93// --done magenta, --bad red, --neutral dim, and yellow for what waits
94// on the viewer (the web's orange).
90func stateColor(s string) string { 95func stateColor(s string) string {
91 switch s { 96 switch s {
92 case "open", "success", "approved", "active": 97 case "open", "success", "approved", "active", "verified", "ok":
93 return sgrGreen 98 return sgrGreen
94 case "merged": 99 case "merged":
95 return sgrMagenta 100 return sgrMagenta
96 case "failed", "failure", "error", "changes requested": 101 case "failed", "failure", "error", "changes requested", "private",
102 "bad_signature", "signed_email_mismatch", "signed_key_expired", "signed_key_revoked":
97 return sgrRed 103 return sgrRed
98 case "closed", "draft", "pending", "canceled", "cancelled", "archived", "disabled": 104 case "closed", "draft", "pending", "canceled", "cancelled", "archived", "disabled",
105 "unsigned", "signed_unknown_key":
99 return sgrDim 106 return sgrDim
107 case "unverified":
108 return sgrYellow
100 } 109 }
101 return "" 110 return ""
102} 111}
103 112
113// paintState colours each word of a state cell: "private, archived"
114// is two states, each in its own colour.
115func (t Term) paintState(s string) string {
116 if !t.Color {
117 return s
118 }
119 words := strings.Split(s, ", ")
120 for i, w := range words {
121 words[i] = t.paint(stateColor(w), w)
122 }
123 return strings.Join(words, ", ")
124}
125
126// heading is a section label at a terminal: capitalised, no trailing
127// colon, bold.
128func (t Term) heading(label string) string {
129 label = strings.TrimSuffix(label, ":")
130 if r, size := utf8.DecodeRuneInString(label); size > 0 {
131 label = string(unicode.ToUpper(r)) + label[size:]
132 }
133 return t.paint(sgrBold, label)
134}
135
136// failure is a refusal as a terminal shows it: "error: " in red ahead
137// of the message, and a usage line wrapped to the width between its
138// bracketed groups, continuation lines indented under the command.
139func (t Term) failure(msg string) string {
140 lines := strings.Split(termSafe(msg), "\n")
141 for i, line := range lines {
142 if rest, ok := strings.CutPrefix(line, "usage: "); ok {
143 lines[i] = "usage: " + wrapUsage(rest, t.Cols-len("usage: "), strings.Repeat(" ", len("usage: ")))
144 } else if i == 0 {
145 lines[i] = t.paint(sgrBold+sgrRed, "error:") + " " + line
146 }
147 }
148 return strings.Join(lines, "\n")
149}
150
151// wrapUsage packs a usage line into lines of at most width cells,
152// breaking only between words outside brackets, so "[--state
153// open|closed|all]" and "[--label <l>]" are never split.
154func wrapUsage(u string, width int, indent string) string {
155 var words []string
156 depth, start := 0, 0
157 for i, r := range u {
158 switch r {
159 case '[', '<':
160 depth++
161 case ']', '>':
162 depth = max(0, depth-1)
163 case ' ':
164 if depth == 0 {
165 if i > start {
166 words = append(words, u[start:i])
167 }
168 start = i + 1
169 }
170 }
171 }
172 if start < len(u) {
173 words = append(words, u[start:])
174 }
175 var b strings.Builder
176 used := 0
177 for _, w := range words {
178 n := cells(w)
179 switch {
180 case used == 0:
181 case w == "|" || used+1+n > width:
182 b.WriteString("\n" + indent)
183 used = 0
184 default:
185 b.WriteByte(' ')
186 used++
187 }
188 b.WriteString(w)
189 used += n
190 }
191 return b.String()
192}
193
104// cells is the width of s in terminal cells: SGR sequences and 194// cells is the width of s in terminal cells: SGR sequences and
105// combining marks take none, East Asian wide and fullwidth runes two. 195// combining marks take none, East Asian wide and fullwidth runes two.
106func cells(s string) int { 196func cells(s string) int {
@@ -179,24 +269,54 @@ func stamp(s string) string {
179 return t.Format("2006-01-02T15:04:05Z") 269 return t.Format("2006-01-02T15:04:05Z")
180} 270}
181 271
182// relAge is a stored timestamp as a table shows it at a terminal. 272// relAge is a stored timestamp as a table shows it at a terminal: "2h
273// ago" in the past, "in 2h" in the future, a date beyond eight weeks
274// either way.
183func relAge(s string, now time.Time) string { 275func relAge(s string, now time.Time) string {
184 t, ok := parseStamp(s) 276 t, ok := parseStamp(s)
185 if !ok { 277 if !ok {
186 return s 278 return s
187 } 279 }
188 d := max(now.Sub(t), 0) 280 d := now.Sub(t)
281 future := d < 0
282 if future {
283 d = -d
284 }
285 var n string
189 switch { 286 switch {
190 case d < time.Minute: 287 case d < time.Minute:
191 return "just now" 288 return "just now"
192 case d < time.Hour: 289 case d < time.Hour:
193 return fmt.Sprintf("%dm ago", int(d/time.Minute)) 290 n = fmt.Sprintf("%dm", int(d/time.Minute))
194 case d < 24*time.Hour: 291 case d < 24*time.Hour:
195 return fmt.Sprintf("%dh ago", int(d/time.Hour)) 292 n = fmt.Sprintf("%dh", int(d/time.Hour))
196 case d < 14*24*time.Hour: 293 case d < 14*24*time.Hour:
197 return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) 294 n = fmt.Sprintf("%dd", int(d/(24*time.Hour)))
198 case d < 56*24*time.Hour: 295 case d < 56*24*time.Hour:
199 return fmt.Sprintf("%dw ago", int(d/(7*24*time.Hour))) 296 n = fmt.Sprintf("%dw", int(d/(7*24*time.Hour)))
297 default:
298 return t.Format("2006-01-02")
299 }
300 if future {
301 return "in " + n
302 }
303 return n + " ago"
304}
305
306// size is a byte count: the number in plain output, KiB and up at a
307// terminal.
308func (t Term) size(n int64) string {
309 if t.Cols == 0 {
310 return strconv.FormatInt(n, 10)
311 }
312 return humanBytes(n)
313}
314
315// dur is a number of seconds: "<n>s" in plain output, hours, minutes
316// and seconds at a terminal.
317func (t Term) dur(secs int64) string {
318 if t.Cols == 0 {
319 return fmt.Sprintf("%ds", secs)
200 } 320 }
201 return t.Format("2006-01-02") 321 return (time.Duration(secs) * time.Second).String()
202} 322}
internal/control/term_test.go +68 −10
@@ -7,15 +7,18 @@ import (
7 7
8func TestParseTerm(t *testing.T) { 8func TestParseTerm(t *testing.T) {
9 cases := map[string]Term{ 9 cases := map[string]Term{
10 "120": {Cols: 120}, 10 "120": {Cols: 120},
11 "120,color": {Cols: 120, Color: true}, 11 "120,color": {Cols: 120, Color: true},
12 "40": {Cols: 40}, 12 "40": {Cols: 40},
13 "39": {}, 13 "39": {},
14 "": {}, 14 "": {},
15 "abc": {}, 15 "abc": {},
16 "80,blink": {}, 16 "80,blink": {Cols: 80},
17 "80,": {}, 17 "80,": {Cols: 80},
18 "5000": {}, 18 "80,truecolor,color": {Cols: 80, Color: true},
19 "80,color,links": {Cols: 80, Color: true},
20 "abc,color": {},
21 "5000": {},
19 } 22 }
20 for in, want := range cases { 23 for in, want := range cases {
21 if got := ParseTerm(in); got != want { 24 if got := ParseTerm(in); got != want {
@@ -70,7 +73,10 @@ func TestStampAndRelAge(t *testing.T) {
70 "2026-09-20T12:00:00Z": "3d ago", 73 "2026-09-20T12:00:00Z": "3d ago",
71 "2026-09-02T12:00:00Z": "3w ago", 74 "2026-09-02T12:00:00Z": "3w ago",
72 "2026-06-01T12:00:00Z": "2026-06-01", 75 "2026-06-01T12:00:00Z": "2026-06-01",
73 "2026-09-24T12:00:00Z": "just now", 76 "2026-09-23T12:00:20Z": "just now",
77 "2026-09-23T17:00:00Z": "in 5h",
78 "2026-09-24T12:00:00Z": "in 1d",
79 "2027-09-24T12:00:00Z": "2027-09-24",
74 "not a time": "not a time", 80 "not a time": "not a time",
75 } 81 }
76 for in, want := range cases { 82 for in, want := range cases {
@@ -79,3 +85,55 @@ func TestStampAndRelAge(t *testing.T) {
79 } 85 }
80 } 86 }
81} 87}
88
89func TestSizeAndDur(t *testing.T) {
90 plain, term := Term{}, Term{Cols: 80}
91 if got := plain.size(3177346); got != "3177346" {
92 t.Errorf("plain size = %q", got)
93 }
94 if got := term.size(3177346); got != "3.0 MiB" {
95 t.Errorf("term size = %q", got)
96 }
97 if got := plain.dur(3223); got != "3223s" {
98 t.Errorf("plain dur = %q", got)
99 }
100 if got := term.dur(3223); got != "53m43s" {
101 t.Errorf("term dur = %q", got)
102 }
103}
104
105func TestPaintStateEachWord(t *testing.T) {
106 got := Term{Cols: 80, Color: true}.paintState("private, archived")
107 want := sgrRed + "private" + sgrReset + ", " + sgrDim + "archived" + sgrReset
108 if got != want {
109 t.Errorf("paintState = %q, want %q", got, want)
110 }
111 if got := (Term{Cols: 80}).paintState("private, archived"); got != "private, archived" {
112 t.Errorf("no colour = %q", got)
113 }
114}
115
116func TestHeading(t *testing.T) {
117 if got := (Term{Cols: 80}).heading("waiting on your review:"); got != "Waiting on your review" {
118 t.Errorf("heading = %q", got)
119 }
120}
121
122func TestFailureAtTerminal(t *testing.T) {
123 msg := "unknown flag \"--stat\"; did you mean --state?\nusage: gitbay issue list [<owner/name>] [--state open|closed|all] [--label <l>] [--assignee <user>]"
124 got := Term{Cols: 50}.failure(msg)
125 want := "error: unknown flag \"--stat\"; did you mean --state?\n" +
126 "usage: gitbay issue list [<owner/name>]\n" +
127 " [--state open|closed|all] [--label <l>]\n" +
128 " [--assignee <user>]"
129 if got != want {
130 t.Errorf("failure:\n%s\nwant\n%s", got, want)
131 }
132 alt := Term{Cols: 80}.failure("usage: gitbay issue list [--limit <n>] | issue list --query <name> | --q <query>")
133 if alt != "usage: gitbay issue list [--limit <n>]\n | issue list --query <name>\n | --q <query>" {
134 t.Errorf("alternatives:\n%s", alt)
135 }
136 if got := (Term{Cols: 80}).failure("usage: gitbay mr merge <n>"); got != "usage: gitbay mr merge <n>" {
137 t.Errorf("bare usage = %q", got)
138 }
139}
internal/control/token.go +1 −1
@@ -121,7 +121,7 @@ func runTokenList(c *Ctx, args []string) int {
121 return c.emit(ds, func(w io.Writer) { 121 return c.emit(ds, func(w io.Writer) {
122 tb := c.table(w, "NAME", "SCOPE", "EXPIRES") 122 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
123 for _, d := range ds { 123 for _, d := range ds {
124 tb.row(cRef(d.Name), cState(d.Scope), cText(expiresText(d.ExpiresAt, now))) 124 tb.row(cRef(d.Name), cState(d.Scope), cText(c.expiresText(d.ExpiresAt, now)))
125 } 125 }
126 tb.flush() 126 tb.flush()
127 }) 127 })
internal/control/view.go +5 −5
@@ -52,8 +52,8 @@ func (v *view) sep() {
52 v.wrote = true 52 v.wrote = true
53} 53}
54 54
55// section prints a sub-table's label: a blank line, then the label bold 55// section prints a sub-table's label: a blank line, then the label
56// at a terminal or "label:" in plain. Callers skip the call entirely 56// capitalised and bold at a terminal, or "label:" in plain. Callers skip the call entirely
57// when the table it introduces has no rows. 57// when the table it introduces has no rows.
58func (v *view) section(label string) { 58func (v *view) section(label string) {
59 v.sep() 59 v.sep()
@@ -61,7 +61,7 @@ func (v *view) section(label string) {
61 io.WriteString(v.w, label+":\n") 61 io.WriteString(v.w, label+":\n")
62 return 62 return
63 } 63 }
64 io.WriteString(v.w, v.c.Term.paint(sgrBold, label)+"\n") 64 io.WriteString(v.w, v.c.Term.heading(label)+"\n")
65} 65}
66 66
67// title prints "ref title state", wrapping title+state to the 67// title prints "ref title state", wrapping title+state to the
@@ -96,14 +96,14 @@ func (v *view) title(ref, title, state string) {
96 rest += state 96 rest += state
97 } 97 }
98 if rest == "" { 98 if rest == "" {
99 io.WriteString(v.w, ref+"\n") 99 io.WriteString(v.w, t.paint(sgrCyan, ref)+"\n")
100 return 100 return
101 } 101 }
102 lines := termtext.Wrap(rest, t.Cols-cells(prefix)) 102 lines := termtext.Wrap(rest, t.Cols-cells(prefix))
103 for i, line := range lines { 103 for i, line := range lines {
104 p := indent 104 p := indent
105 if i == 0 { 105 if i == 0 {
106 p = prefix 106 p = t.paint(sgrCyan, ref) + " "
107 } 107 }
108 if i < len(lines)-1 || state == "" { 108 if i < len(lines)-1 || state == "" {
109 io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n") 109 io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n")
internal/control/web.go +4 −6
@@ -42,13 +42,11 @@ func runWebSessionsList(c *Ctx, args []string) int {
42 return c.emit(sessions, func(w io.Writer) { 42 return c.emit(sessions, func(w io.Writer) {
43 tb := c.table(w, "ID", "SINCE", "UNTIL", "USED") 43 tb := c.table(w, "ID", "SINCE", "UNTIL", "USED")
44 for _, s := range sessions { 44 for _, s := range sessions {
45 since, until := s.CreatedAt, s.ExpiresAt 45 if c.Term.Cols > 0 {
46 if c.Term.Cols == 0 { 46 tb.row(cRef(s.ID), cAge(s.CreatedAt), cAge(s.ExpiresAt), cText(c.usedText(s.LastUsedAt)))
47 since, until = stamp(since), stamp(until) 47 continue
48 } else {
49 since, until = relAge(since, termNow()), relAge(until, termNow())
50 } 48 }
51 tb.row(cRef(s.ID), cText("since "+since), cText("until "+until), cText(c.usedText(s.LastUsedAt))) 49 tb.row(cRef(s.ID), cText("since "+stamp(s.CreatedAt)), cText("until "+stamp(s.ExpiresAt)), cText(c.usedText(s.LastUsedAt)))
52 } 50 }
53 tb.flush() 51 tb.flush()
54 }) 52 })