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.
10021002
10031003** At a terminal
10041004
1005The =gitbay= CLI sends a leading =--term=<cols>[,color]= argument on
1005The =gitbay= CLI sends a leading =--term=<cols>[,<option>]...= argument on
10061006the SSH command line when stdout is a terminal (OpenSSH's multiplexed
10071007sessions, which the CLI uses, do not forward a session's =SetEnv=).
10081008The 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:
10101012
10111013- lists under a header, padded, fitted to the width (the title or
1012 description column is cut with =…= first), states in colour, ages as
1013 =2h ago=, and the next page as a command on stderr (piped output
1014 keeps a =next\t<cursor>= row instead);
1014 description column is cut with =…= first; a title or description
1015 column blank on most rows is held to a third of the width), with no
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;
10151027- =show= views with a title line, aligned fields, the body rendered
10161028 from markdown or org, one line per event, and comments under a rule;
10171029 a sub-table (labels, revisions, and the like) carries a section label
10181030 line in both terminal and piped output; timestamps as
10191031 =2026-09-23 23:26 UTC=. =wiki show= piped prints the page source
10201032 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;
10211036- help with flag descriptions, defaults, and examples; =gitbay --help=
10221037 groups commands under WORK, REPOSITORIES, YOU and INSTANCE;
10231038 =help --json= adds =flags= and =examples=.
CHANGELOG.org +11
@@ -16,6 +16,17 @@ anything beyond "replace the binary and restart" is needed.
1616 draft controls to its author as well as to writers, matching the
1717 commands: an author without write access could do it over SSH but had
1818 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.
1930
2031* v1.40.1 — 2026-09-29
2132
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 {
312312 }
313313 cells := []cell{cRef(e.Address), cState(state)}
314314 if e.Primary {
315 cells = append(cells, cText("primary"))
315 cells = c.note(cells, 1, "primary", "primary")
316316 }
317317 te.row(cells...)
318318 }
@@ -442,9 +442,9 @@ func runAdminRepoList(c *Ctx, args []string) int {
442442 return c.emitPage(p, ds, next, func(w io.Writer) {
443443 tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH")
444444 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)}
446446 if d.Archived {
447 cells = append(cells, cText("[archived]"))
447 cells = c.note(cells, 1, "[archived]", "archived")
448448 }
449449 tb.row(cells...)
450450 }
@@ -579,8 +579,8 @@ func runAdminRunners(c *Ctx, args []string) int {
579579 v.fields(
580580 "pending", fmt.Sprintf("%d", queue.Pending),
581581 "claimed 24h", fmt.Sprintf("%d", queue.Claimed24h),
582 "wait avg", fmt.Sprintf("%ds", queue.ClaimWaitAvgS),
583 "wait max", fmt.Sprintf("%ds", queue.ClaimWaitMaxS),
582 "wait avg", c.Term.dur(queue.ClaimWaitAvgS),
583 "wait max", c.Term.dur(queue.ClaimWaitMaxS),
584584 "reaped 24h", fmt.Sprintf("%d", queue.Reaped24h),
585585 )
586586 if len(runners) > 0 {
internal/control/control.go +3
@@ -419,6 +419,9 @@ func (c *Ctx) fail(code int, format string, args ...any) int {
419419 enc.SetEscapeHTML(false)
420420 enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg})
421421 } else {
422 if c.Term.Cols > 0 {
423 msg = c.Term.failure(msg)
424 }
422425 fmt.Fprintln(c.Stderr, msg)
423426 }
424427 return code
internal/control/dashboard.go +21 −7
@@ -177,12 +177,25 @@ func runDashboard(c *Ctx, args []string) int {
177177 }
178178
179179 return c.emit(d, func(w io.Writer) {
180 section := func(title string, header []string, rows [][]cell) {
181 if c.Term.Cols > 0 {
182 fmt.Fprintln(w, c.Term.paint(sgrBold, title))
183 } else {
180 // At a terminal sections are separated by a blank line, and an
181 // empty one is left out.
182 wrote := false
183 heading := func(title string) {
184 if c.Term.Cols == 0 {
184185 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
185197 }
198 heading(title)
186199 if len(rows) == 0 {
187200 fmt.Fprintln(w, " none")
188201 return
@@ -238,7 +251,7 @@ func runDashboard(c *Ctx, args []string) int {
238251 for i, p := range d.Pinned {
239252 cells := []cell{cRef(p.Path), cState(p.Visibility), cFlex(p.Description)}
240253 if p.Archived {
241 cells = append(cells, cText("[archived]"))
254 cells = c.note(cells, 1, "[archived]", "archived")
242255 }
243256 pinnedRows[i] = cells
244257 }
@@ -258,10 +271,11 @@ func runDashboard(c *Ctx, args []string) int {
258271 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows)
259272
260273 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)
262276 }
263277 if q := d.Queues; q != nil {
264 fmt.Fprintln(w, "queues:")
278 heading("queues:")
265279
266280 fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed)
267281 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 {
8484 return c.emit(d, func(w io.Writer) {
8585 line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path())
8686 if expires != nil {
87 line += ", expires " + expiresText(expires, time.Now())
87 line += ", expires " + c.expiresText(expires, time.Now())
8888 }
8989 fmt.Fprintln(w, line)
9090 })
@@ -123,7 +123,7 @@ func runDeployKeyList(c *Ctx, args []string) int {
123123 tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES")
124124 for _, d := range ds {
125125 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)))
127127 }
128128 tb.flush()
129129 })
internal/control/flags.go +45
@@ -80,6 +80,9 @@ func parseFlags(args []string, spec flagSpec) (flags, error) {
8080 }
8181 i++
8282 default:
83 if near := nearestFlag(a, kind); near != "" {
84 return f, usage("unknown flag %q; did you mean %s?", a, near)
85 }
8386 return f, usage("unknown flag %q", a)
8487 }
8588 continue
@@ -92,6 +95,48 @@ func parseFlags(args []string, spec flagSpec) (flags, error) {
9295 return f, nil
9396}
9497
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
95140// parseArgs is parseFlags for the running command, with the usage line
96141// printed the way a usage refusal prints it (cmdUsage): the program in
97142// 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) {
3131 t.Fatalf("-- handling: %v %+v", err, f)
3232 }
3333}
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 {
102102 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES")
103103 for _, d := range ds {
104104 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)))
106106 }
107107 tb.flush()
108108 })
@@ -138,13 +138,16 @@ func (c *Ctx) usedText(ts string) string {
138138 return relAge(ts, termNow())
139139}
140140
141// expiresText is a credential's expiry as an EXPIRES cell shows it. It
142// is absolute at a terminal too: relAge reads only the past.
143func expiresText(t *time.Time, now time.Time) string {
141// expiresText is a credential's expiry as an EXPIRES cell shows it:
142// RFC3339 in plain output, relative at a terminal.
143func (c *Ctx) expiresText(t *time.Time, now time.Time) string {
144144 if t == nil {
145145 return "never"
146146 }
147147 s := stamp(t.UTC().Format(time.RFC3339Nano))
148 if c.Term.Cols > 0 {
149 s = relAge(s, now)
150 }
148151 if !t.After(now) {
149152 return "expired " + s
150153 }
@@ -204,7 +207,7 @@ func runKeysAdd(c *Ctx, args []string) int {
204207 line += " " + d.Label
205208 }
206209 if d.ExpiresAt != nil {
207 line += ", expires " + expiresText(d.ExpiresAt, time.Now())
210 line += ", expires " + c.expiresText(d.ExpiresAt, time.Now())
208211 }
209212 fmt.Fprintln(w, line)
210213 })
internal/control/profile.go +4 −4
@@ -265,7 +265,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
265265 "activity", activity,
266266 )
267267 if len(d.Links) > 0 {
268 v.section("link")
268 v.section("links")
269269 tb := c.table(w, "LINK", "URL")
270270 for _, l := range d.Links {
271271 tb.row(cText(l.Label), cFlex(l.URL))
@@ -273,7 +273,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
273273 tb.flush()
274274 }
275275 if len(d.Orgs) > 0 {
276 v.section("org")
276 v.section("orgs")
277277 tb := c.table(w, "ORG", "ROLE")
278278 for _, m := range d.Orgs {
279279 tb.row(cRef(m.Name), cState(m.Role))
@@ -281,7 +281,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
281281 tb.flush()
282282 }
283283 if len(d.Members) > 0 {
284 v.section("member")
284 v.section("members")
285285 tb := c.table(w, "MEMBER", "ROLE")
286286 for _, m := range d.Members {
287287 tb.row(cRef(m.Name), cState(m.Role))
@@ -289,7 +289,7 @@ func emitProfile(c *Ctx, d ProfileOut) int {
289289 tb.flush()
290290 }
291291 if len(d.Repos) > 0 {
292 v.section("repo")
292 v.section("repos")
293293 tb := c.table(w, "REPO", "VISIBILITY", "DESCRIPTION")
294294 for _, r := range d.Repos {
295295 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 {
317317 if e.Type == "tree" {
318318 name += "/"
319319 }
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))
321325 }
322326 tb.flush()
323327 })
@@ -390,13 +394,6 @@ func runRepoReadme(c *Ctx, args []string) int {
390394 })
391395}
392396
393func sizeCol(e entryOut) string {
394 if e.Type == "tree" {
395 return "-"
396 }
397 return fmt.Sprintf("%d", e.Size)
398}
399
400397// readmeRank orders competing README files: richer renderers win.
401398var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
402399
internal/control/register.go +1 −1
@@ -82,7 +82,7 @@ func runEmailList(c *Ctx, args []string) int {
8282 }
8383 cells := []cell{cRef(d.Address), cState(state)}
8484 if d.Primary {
85 cells = append(cells, cText("primary"))
85 cells = c.note(cells, 1, "primary", "primary")
8686 }
8787 tb.row(cells...)
8888 }
internal/control/release.go +1 −1
@@ -313,7 +313,7 @@ func runReleaseShow(c *Ctx, args []string) int {
313313 v.section("assets")
314314 tb := c.table(w, "NAME", "SIZE", "SHA256")
315315 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))
317317 }
318318 tb.flush()
319319 }
internal/control/repo.go +1 −1
@@ -365,7 +365,7 @@ func runRepoList(c *Ctx, args []string) int {
365365 for _, d := range ds {
366366 cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)}
367367 if d.Archived {
368 cells = append(cells, cText("[archived]"))
368 cells = c.note(cells, 1, "[archived]", "archived")
369369 }
370370 tb.row(cells...)
371371 }
internal/control/table.go +50 −5
@@ -15,6 +15,7 @@ const (
1515 kindState
1616 kindAge
1717 kindNum
18 kindSize
1819)
1920
2021// 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} }
3031func cFlex(s string) cell { return cell{kindFlex, s} }
3132func cAge(ts string) cell { return cell{kindAge, ts} }
3233func cNum(n int64) cell { return cell{kindNum, strconv.FormatInt(n, 10)} }
34func cSize(n int64) cell { return cell{kindSize, strconv.FormatInt(n, 10)} }
3335
3436// table is a list command's rows. Plain, each row is written as it
3537// comes, tab-separated with no header. At a terminal rows are held
@@ -42,6 +44,18 @@ type table struct {
4244 rows [][]cell
4345}
4446
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
4559func (c *Ctx) table(w io.Writer, header ...string) *table {
4660 return &table{term: c.Term, w: w, header: header}
4761}
@@ -62,8 +76,13 @@ func (t *table) row(cs ...cell) {
6276 now := termNow()
6377 for i := range cs {
6478 cs[i].s = termSafe(cs[i].s)
65 if cs[i].kind == kindAge {
79 switch cs[i].kind {
80 case kindAge:
6681 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 }
6786 }
6887 }
6988 t.rows = append(t.rows, cs)
@@ -89,6 +108,7 @@ func (t *table) flush() {
89108 widths[i] = max(widths[i], cells(r[i].s))
90109 }
91110 }
111 t.capSparse(widths)
92112 t.fit(widths)
93113
94114 var b strings.Builder
@@ -98,7 +118,7 @@ func (t *table) flush() {
98118 line[i] = clip(t.header[i], widths[i])
99119 }
100120 }
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")
102122 for _, r := range t.rows {
103123 for i := 0; i < n; i++ {
104124 s := ""
@@ -107,11 +127,31 @@ func (t *table) flush() {
107127 }
108128 line[i] = s
109129 }
110 b.WriteString(t.joinRow(r, line, widths) + "\n")
130 b.WriteString(strings.TrimRight(t.joinRow(r, line, widths), " ") + "\n")
111131 }
112132 io.WriteString(t.w, b.String())
113133}
114134
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
115155// fit shrinks columns until a row fits the terminal: the flexible
116156// column first, down to 8 cells, then the other text columns from the
117157// right, down to 8 each.
@@ -177,8 +217,13 @@ func (t *table) joinRow(r []cell, line []string, widths []int) string {
177217 if i < len(line)-1 {
178218 padding = strings.Repeat(" ", max(0, widths[i]-cells(s)))
179219 }
180 if i < len(r) && r[i].kind == kindState {
181 s = t.term.paint(stateColor(s), s)
220 if i < len(r) {
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 }
182227 }
183228 b.WriteString(s + padding)
184229 }
internal/control/table_test.go +47
@@ -124,3 +124,50 @@ func TestTableEmptyPrintsNothing(t *testing.T) {
124124 t.Errorf("empty table printed %q", b.String())
125125 }
126126}
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 {
1919 Color bool
2020}
2121
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
2325// 40 to 1000, is plain output.
2426func ParseTerm(v string) Term {
25 cols, opt, hasOpt := strings.Cut(v, ",")
26 n, err := strconv.Atoi(cols)
27 parts := strings.Split(v, ",")
28 n, err := strconv.Atoi(parts[0])
2729 if err != nil || n < 40 || n > 1000 {
2830 return Term{}
2931 }
30 switch {
31 case !hasOpt:
32 return Term{Cols: n}
33 case opt == "color":
34 return Term{Cols: n, Color: true}
32 t := Term{Cols: n}
33 for _, opt := range parts[1:] {
34 if opt == "color" {
35 t.Color = true
36 }
3537 }
36 return Term{}
38 return t
3739}
3840
3941const (
@@ -42,7 +44,9 @@ const (
4244 sgrDim = "\x1b[2m"
4345 sgrRed = "\x1b[31m"
4446 sgrGreen = "\x1b[32m"
47 sgrYellow = "\x1b[33m"
4548 sgrMagenta = "\x1b[35m"
49 sgrCyan = "\x1b[36m"
4650)
4751
4852// termSafe replaces the bytes a terminal would act on — ESC, the C0
@@ -86,21 +90,107 @@ func (t Term) paint(sgr, s string) string {
8690}
8791
8892// 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).
9095func stateColor(s string) string {
9196 switch s {
92 case "open", "success", "approved", "active":
97 case "open", "success", "approved", "active", "verified", "ok":
9398 return sgrGreen
9499 case "merged":
95100 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":
97103 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":
99106 return sgrDim
107 case "unverified":
108 return sgrYellow
100109 }
101110 return ""
102111}
103112
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
104194// cells is the width of s in terminal cells: SGR sequences and
105195// combining marks take none, East Asian wide and fullwidth runes two.
106196func cells(s string) int {
@@ -179,24 +269,54 @@ func stamp(s string) string {
179269 return t.Format("2006-01-02T15:04:05Z")
180270}
181271
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.
183275func relAge(s string, now time.Time) string {
184276 t, ok := parseStamp(s)
185277 if !ok {
186278 return s
187279 }
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
189286 switch {
190287 case d < time.Minute:
191288 return "just now"
192289 case d < time.Hour:
193 return fmt.Sprintf("%dm ago", int(d/time.Minute))
290 n = fmt.Sprintf("%dm", int(d/time.Minute))
194291 case d < 24*time.Hour:
195 return fmt.Sprintf("%dh ago", int(d/time.Hour))
292 n = fmt.Sprintf("%dh", int(d/time.Hour))
196293 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)))
198295 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)
200320 }
201 return t.Format("2006-01-02")
321 return (time.Duration(secs) * time.Second).String()
202322}
internal/control/term_test.go +68 −10
@@ -7,15 +7,18 @@ import (
77
88func TestParseTerm(t *testing.T) {
99 cases := map[string]Term{
10 "120": {Cols: 120},
11 "120,color": {Cols: 120, Color: true},
12 "40": {Cols: 40},
13 "39": {},
14 "": {},
15 "abc": {},
16 "80,blink": {},
17 "80,": {},
18 "5000": {},
10 "120": {Cols: 120},
11 "120,color": {Cols: 120, Color: true},
12 "40": {Cols: 40},
13 "39": {},
14 "": {},
15 "abc": {},
16 "80,blink": {Cols: 80},
17 "80,": {Cols: 80},
18 "80,truecolor,color": {Cols: 80, Color: true},
19 "80,color,links": {Cols: 80, Color: true},
20 "abc,color": {},
21 "5000": {},
1922 }
2023 for in, want := range cases {
2124 if got := ParseTerm(in); got != want {
@@ -70,7 +73,10 @@ func TestStampAndRelAge(t *testing.T) {
7073 "2026-09-20T12:00:00Z": "3d ago",
7174 "2026-09-02T12:00:00Z": "3w ago",
7275 "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",
7480 "not a time": "not a time",
7581 }
7682 for in, want := range cases {
@@ -79,3 +85,55 @@ func TestStampAndRelAge(t *testing.T) {
7985 }
8086 }
8187}
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 {
121121 return c.emit(ds, func(w io.Writer) {
122122 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
123123 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)))
125125 }
126126 tb.flush()
127127 })
internal/control/view.go +5 −5
@@ -52,8 +52,8 @@ func (v *view) sep() {
5252 v.wrote = true
5353}
5454
55// section prints a sub-table's label: a blank line, then the label bold
56// at a terminal or "label:" in plain. Callers skip the call entirely
55// section prints a sub-table's label: a blank line, then the label
56// capitalised and bold at a terminal, or "label:" in plain. Callers skip the call entirely
5757// when the table it introduces has no rows.
5858func (v *view) section(label string) {
5959 v.sep()
@@ -61,7 +61,7 @@ func (v *view) section(label string) {
6161 io.WriteString(v.w, label+":\n")
6262 return
6363 }
64 io.WriteString(v.w, v.c.Term.paint(sgrBold, label)+"\n")
64 io.WriteString(v.w, v.c.Term.heading(label)+"\n")
6565}
6666
6767// title prints "ref title state", wrapping title+state to the
@@ -96,14 +96,14 @@ func (v *view) title(ref, title, state string) {
9696 rest += state
9797 }
9898 if rest == "" {
99 io.WriteString(v.w, ref+"\n")
99 io.WriteString(v.w, t.paint(sgrCyan, ref)+"\n")
100100 return
101101 }
102102 lines := termtext.Wrap(rest, t.Cols-cells(prefix))
103103 for i, line := range lines {
104104 p := indent
105105 if i == 0 {
106 p = prefix
106 p = t.paint(sgrCyan, ref) + " "
107107 }
108108 if i < len(lines)-1 || state == "" {
109109 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 {
4242 return c.emit(sessions, func(w io.Writer) {
4343 tb := c.table(w, "ID", "SINCE", "UNTIL", "USED")
4444 for _, s := range sessions {
45 since, until := s.CreatedAt, s.ExpiresAt
46 if c.Term.Cols == 0 {
47 since, until = stamp(since), stamp(until)
48 } else {
49 since, until = relAge(since, termNow()), relAge(until, termNow())
45 if c.Term.Cols > 0 {
46 tb.row(cRef(s.ID), cAge(s.CreatedAt), cAge(s.ExpiresAt), cText(c.usedText(s.LastUsedAt)))
47 continue
5048 }
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)))
5250 }
5351 tb.flush()
5452 })