Commit 4837660d33

4837660d3339aea30f511473d27e2ea4028465d3

parent: 791af1d907

Verified · cmc ci/build: success ci/test: success

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

control: terminal rendering foundations

At a terminal: colour by meaning (private red, signature states,
references cyan, yellow only for what waits on the viewer), future
times as "in 5h", sizes and durations humanized, markers joined to the
state cell instead of an unnamed column, no trailing whitespace, a
mostly blank flex column capped at a third of the width, one heading
style with empty dashboard sections left out, and errors after a red
"error:" with the usage wrapped between bracketed groups.

Everywhere: an unknown flag names the nearest flag, profile show's
section labels are plural, and ParseTerm ignores options it does not
know.

Closes #312

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
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 })