Commit 135f300c0b

135f300c0b4dcaf8233f962e714cdd7b9e97ee22

parent: a0aa68a7ca

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

cmc <hello@cleberg.net> · 2026-10-01 21:16 UTC

cli, control: truecolor label swatches and OSC 8 links

The CLI adds truecolor to --term when COLORTERM is truecolor or 24bit,
and links in terminals known to show OSC 8 (or GITBAY_LINKS=1;
GITBAY_LINKS=0 stops it). GITBAY_TERM=basic sends width and colour
only, for an instance older than these options.

The server renders a label's colour as a dot in that colour beside the
hex (cSwatch), and references in lists, search, the dashboard and the
feed as links to their page (cLink). Checks read with a glyph:
"✓ 3/3", "✗ 1 failed", "• 2 pending".

Closes #315

Layout: unified · split

.gitbay/wiki/Users.org +11
@@ -1055,6 +1055,17 @@ then prints:
10551055instance older than v1.36.0, which refuses the argument as an unknown
10561056command.
10571057
1058Two more options say what the terminal can show. =truecolor= (sent when
1059=COLORTERM= is =truecolor= or =24bit=) puts a dot in each label's own
1060colour beside its hex in =label list=. =links= (sent in iTerm2, WezTerm,
1061Ghostty, VS Code, kitty and VTE terminals, or when =GITBAY_LINKS=1=;
1062=GITBAY_LINKS=0= stops it) makes references in lists open their page,
1063as OSC 8 hyperlinks. A terminal outside that list would print the
1064escape's text, so it is not sent there by default. =GITBAY_TERM=basic=
1065sends only the width and colour, for an instance older than the release
1066that added these options, which turns any option it does not know into
1067plain output.
1068
10581069Stock ssh without the CLI's multiplexing gets the plain output unless
10591070it passes the same leading argument or sets the environment variable
10601071sshd is told to accept. OpenSSH parses options after the host, so the
CHANGELOG.org +9
@@ -50,6 +50,15 @@ anything beyond "replace the binary and restart" is needed.
5050 key and scope; =auth keys list= marks this session's key; issue, merge
5151 request and release creation print the page's URL. A column empty on
5252 every row is left out.
53- The CLI tells the server what the terminal can show (#315):
54 =truecolor= when =COLORTERM= is =truecolor= or =24bit=, for a dot in
55 each label's own colour in =label list=, and =links= in iTerm2,
56 WezTerm, Ghostty, VS Code, kitty and VTE terminals, or with
57 =GITBAY_LINKS=1= (=0= turns it off), for references that open their
58 page (OSC 8). Checks read =✓ 3/3=, =✗ 1 failed=, =• 2 pending=.
59 *Upgrade note:* an instance older than this release turns those
60 options into plain output; set =GITBAY_TERM=basic= for the CLI to send
61 only the width and colour until the instance is upgraded.
5362
5463* v1.40.1 — 2026-09-29
5564
cmd/gitbay/ssh.go +38 −4
@@ -114,20 +114,54 @@ func sshArgs(inst cliconfig.Instance) []string {
114114var noColor bool
115115
116116// termValue is GITBAY_TERM for this invocation: the terminal's width,
117// and whether colour is wanted. Empty when stdout is not a terminal,
118// so piped output stays the rows stock ssh prints, and when GITBAY_TERM
119// is "off", for an instance older than --term.
117// whether colour is wanted, and what else the terminal can show: 24-bit
118// colour and hyperlinks. Empty when stdout is not a terminal, so piped
119// output stays the rows stock ssh prints, and when GITBAY_TERM is "off",
120// for an instance older than --term. GITBAY_TERM=basic sends the width
121// and colour only, for an instance older than truecolor and links, which
122// turns any other option into plain output.
120123func termValue(isTerminal bool, cols int, env func(string) string) string {
121124 if !isTerminal || cols < 40 || env("GITBAY_TERM") == "off" {
122125 return ""
123126 }
124127 v := strconv.Itoa(cols)
125 if !noColor && env("NO_COLOR") == "" && env("TERM") != "dumb" {
128 color := !noColor && env("NO_COLOR") == "" && env("TERM") != "dumb"
129 if color {
126130 v += ",color"
127131 }
132 if env("GITBAY_TERM") == "basic" {
133 return v
134 }
135 if ct := env("COLORTERM"); color && (ct == "truecolor" || ct == "24bit") {
136 v += ",truecolor"
137 }
138 if linksWanted(env) {
139 v += ",links"
140 }
128141 return v
129142}
130143
144// linksWanted reports whether the terminal shows OSC 8 hyperlinks:
145// GITBAY_LINKS=1 or 0 decides, else terminals known to support them.
146// One that does not would print the escape's text, so the default is no.
147func linksWanted(env func(string) string) bool {
148 switch env("GITBAY_LINKS") {
149 case "1":
150 return true
151 case "0":
152 return false
153 }
154 switch env("TERM_PROGRAM") {
155 case "iTerm.app", "WezTerm", "ghostty", "vscode":
156 return true
157 }
158 if env("KITTY_WINDOW_ID") != "" {
159 return true
160 }
161 n, err := strconv.Atoi(env("VTE_VERSION"))
162 return err == nil && n >= 5000
163}
164
131165// stripNoColor removes --no-color wherever it appears.
132166func stripNoColor(args []string) ([]string, bool) {
133167 out := args[:0:0]
cmd/gitbay/term_test.go +8
@@ -26,6 +26,14 @@ func TestTermValue(t *testing.T) {
2626 {true, 120, map[string]string{"TERM": "dumb"}, false, "120"},
2727 {true, 120, nil, true, "120"},
2828 {true, 120, map[string]string{"GITBAY_TERM": "off"}, false, ""},
29 {true, 120, map[string]string{"COLORTERM": "truecolor"}, false, "120,color,truecolor"},
30 {true, 120, map[string]string{"COLORTERM": "24bit", "NO_COLOR": "1"}, false, "120"},
31 {true, 120, map[string]string{"TERM_PROGRAM": "iTerm.app"}, false, "120,color,links"},
32 {true, 120, map[string]string{"TERM_PROGRAM": "iTerm.app", "GITBAY_LINKS": "0"}, false, "120,color"},
33 {true, 120, map[string]string{"TERM_PROGRAM": "Apple_Terminal"}, false, "120,color"},
34 {true, 120, map[string]string{"GITBAY_LINKS": "1"}, false, "120,color,links"},
35 {true, 120, map[string]string{"VTE_VERSION": "7200"}, false, "120,color,links"},
36 {true, 120, map[string]string{"COLORTERM": "truecolor", "GITBAY_LINKS": "1", "GITBAY_TERM": "basic"}, false, "120,color"},
2937 }
3038 for _, c := range cases {
3139 noColor = c.noColor
internal/control/admin.go +1 −1
@@ -442,7 +442,7 @@ 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), cSize(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)}
445 cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cSize(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)}
446446 if d.Archived {
447447 cells = c.note(cells, 1, "[archived]", "archived")
448448 }
internal/control/build.go +1 −1
@@ -216,7 +216,7 @@ func runBuildList(c *Ctx, args []string) int {
216216 return c.emitPage(p, ds, next, func(w io.Writer) {
217217 tb := c.table(w, "#", "JOB", "STATUS", "SHA", "REF", "TITLE")
218218 for _, d := range ds {
219 tb.row(cRef(fmt.Sprintf("%d", d.Number)), cText(d.Job), cState(d.Status), cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Ref), cFlex(d.Subject))
219 tb.row(cLink(fmt.Sprintf("%d", d.Number), c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10))), cText(d.Job), cState(d.Status), cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Ref), cFlex(d.Subject))
220220 }
221221 tb.flush()
222222 })
internal/control/dashboard.go +10 −5
@@ -266,7 +266,12 @@ func runDashboard(c *Ctx, args []string) int {
266266 itemRows := func(items []DashboardItem, marker string) [][]cell {
267267 rows := make([][]cell, len(items))
268268 for i, item := range items {
269 rows[i] = []cell{cRef(fmt.Sprintf("%s%s%d", item.Repo, marker, item.Number)), cFlex(item.Title), cText(item.Author)}
269 page := "issues"
270 if marker == "!" {
271 page = "mrs"
272 }
273 ref := cLink(fmt.Sprintf("%s%s%d", item.Repo, marker, item.Number), c.siteURL(item.Repo, page, strconv.FormatInt(item.Number, 10)))
274 rows[i] = []cell{ref, cFlex(item.Title), cText(item.Author)}
270275 }
271276 return rows
272277 }
@@ -296,7 +301,7 @@ func runDashboard(c *Ctx, args []string) int {
296301
297302 pinnedRows := make([][]cell, len(d.Pinned))
298303 for i, p := range d.Pinned {
299 cells := []cell{cRef(p.Path), cState(p.Visibility), cFlex(p.Description)}
304 cells := []cell{cLink(p.Path, c.siteURL(p.Path)), cState(p.Visibility), cFlex(p.Description)}
300305 if p.Archived {
301306 cells = c.note(cells, 1, "[archived]", "archived")
302307 }
@@ -309,7 +314,7 @@ func runDashboard(c *Ctx, args []string) int {
309314 // The feed has its own command; the dashboard shows the start.
310315 rows := make([][]cell, 0, dashboardActivity)
311316 for _, l := range lines[:min(len(lines), dashboardActivity)] {
312 rows = append(rows, l.termCells())
317 rows = append(rows, l.termCells(c))
313318 }
314319 section("recent activity:", feedHeader, rows)
315320 if len(lines) > dashboardActivity {
@@ -325,7 +330,7 @@ func runDashboard(c *Ctx, args []string) int {
325330
326331 buildRows := make([][]cell, len(d.Builds))
327332 for i, b := range d.Builds {
328 buildRows[i] = []cell{cRef(b.Repo), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)}
333 buildRows[i] = []cell{cLink(b.Repo, c.siteURL(b.Repo, "builds", strconv.FormatInt(b.Number, 10))), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)}
329334 }
330335 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows)
331336
@@ -440,7 +445,7 @@ func runFeed(c *Ctx, args []string) int {
440445 if c.Term.Cols > 0 {
441446 tb := c.table(w, feedHeader...)
442447 for _, l := range lines {
443 tb.row(l.termCells()...)
448 tb.row(l.termCells(c)...)
444449 }
445450 tb.flush()
446451 return
internal/control/feedline.go +2 −2
@@ -47,7 +47,7 @@ func (l FeedLine) Sentence() string {
4747// reference can carry their colours.
4848var feedHeader = []string{"WHEN", "WHO", "EVENT", "WHERE", "DETAIL"}
4949
50func (l FeedLine) termCells() []cell {
50func (l FeedLine) termCells(c *Ctx) []cell {
5151 ref := l.Ref
5252 if ref != "" && ref[0] != '#' && ref[0] != '!' {
5353 ref = " " + ref
@@ -58,7 +58,7 @@ func (l FeedLine) termCells() []cell {
5858 verb = strings.TrimSuffix(verb, " "+l.State)
5959 detail = cState(l.State)
6060 }
61 return []cell{cAge(l.When), cText(l.Actor), cText(verb), cRef(l.Repo + ref), detail}
61 return []cell{cAge(l.When), cText(l.Actor), cText(verb), cLink(l.Repo+ref, c.siteURL(strings.TrimPrefix(l.URL, "/"))), detail}
6262}
6363
6464// FeedLines turns stored events into readable lines. An unknown kind
internal/control/issue.go +1 −1
@@ -363,7 +363,7 @@ func runIssueList(c *Ctx, args []string) int {
363363 if k := comments[issues[i].ID]; k > 0 {
364364 n = strconv.Itoa(k)
365365 }
366 tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title),
366 tb.row(cLink(fmt.Sprintf("#%d", d.Number), c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10))), cState(d.State), cFlex(d.Title),
367367 cText(labelsMark(labels[issues[i].ID])), assigneesMark(assignees[issues[i].ID], c.User.Username),
368368 cText(n), cAge(issues[i].UpdatedAt))
369369 }
internal/control/label.go +1 −1
@@ -57,7 +57,7 @@ func runLabelList(c *Ctx, args []string) int {
5757 return c.emit(labels, func(w io.Writer) {
5858 tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS")
5959 for _, l := range labels {
60 cells := []cell{cRef(l.Name), cText(l.Color), cNum(l.Issues), cNum(l.MRs)}
60 cells := []cell{cRef(l.Name), cSwatch(l.Color), cNum(l.Issues), cNum(l.MRs)}
6161 if l.Org {
6262 cells = append(cells, cText("org"))
6363 }
internal/control/marks.go +6 −4
@@ -9,7 +9,9 @@ import (
99)
1010
1111// checksMark sums a commit's statuses for a list row at a terminal:
12// "2 failed" red, "1 pending" dim, "3/3" green, blank with none.
12// "✗ 2 failed" red, "• 1 pending" dim, "✓ 3/3" green, blank with none.
13// The glyph leads so the column reads at a glance; the words stay for
14// a terminal without colour.
1315func checksMark(sts []store.CommitStatus) cell {
1416 failed, pending, passed := 0, 0, 0
1517 for _, s := range sts {
@@ -26,11 +28,11 @@ func checksMark(sts []store.CommitStatus) cell {
2628 case len(sts) == 0:
2729 return cText("")
2830 case failed > 0:
29 return cMark(fmt.Sprintf("%d failed", failed), sgrRed)
31 return cMark(fmt.Sprintf("✗ %d failed", failed), sgrRed)
3032 case pending > 0:
31 return cMark(fmt.Sprintf("%d pending", pending), sgrDim)
33 return cMark(fmt.Sprintf("• %d pending", pending), sgrDim)
3234 }
33 return cMark(fmt.Sprintf("%d/%d", passed, len(sts)), sgrGreen)
35 return cMark(fmt.Sprintf("✓ %d/%d", passed, len(sts)), sgrGreen)
3436}
3537
3638// reviewMark is where review of an open merge request stands, by the
internal/control/marks_test.go +3 −3
@@ -19,9 +19,9 @@ func TestChecksMark(t *testing.T) {
1919 text, sgr string
2020 }{
2121 {nil, "", ""},
22 {st("success", "success", "skipped"), "3/3", sgrGreen},
23 {st("success", "pending"), "1 pending", sgrDim},
24 {st("success", "failure", "error", "pending"), "2 failed", sgrRed},
22 {st("success", "success", "skipped"), "✓ 3/3", sgrGreen},
23 {st("success", "pending"), "• 1 pending", sgrDim},
24 {st("success", "failure", "error", "pending"), "✗ 2 failed", sgrRed},
2525 }
2626 for _, tc := range cases {
2727 if got := checksMark(tc.in); got.s != tc.text || got.sgr != tc.sgr {
internal/control/mr.go +1 −1
@@ -724,7 +724,7 @@ func mrListTerm(c *Ctx, w io.Writer, repo store.Repo, mrs []store.MR, ds []mrOut
724724 ref += fmt.Sprintf(" (on !%d)", d.StackedOn.Number)
725725 }
726726 m := mrs[i]
727 tb.row(cRef(fmt.Sprintf("!%d", d.Number)), cState(state), cFlex(d.Title), cText(ref),
727 tb.row(cLink(fmt.Sprintf("!%d", d.Number), c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10))), cState(state), cFlex(d.Title), cText(ref),
728728 checks[m.ID], review[m.ID], cAge(m.UpdatedAt))
729729 }
730730 tb.flush()
internal/control/orglabel.go +1 −1
@@ -154,7 +154,7 @@ func runOrgLabelList(c *Ctx, args []string) int {
154154 return c.emit(labels, func(w io.Writer) {
155155 tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS")
156156 for _, l := range labels {
157 tb.row(cRef(l.Name), cText(l.Color), cNum(l.Issues), cNum(l.MRs))
157 tb.row(cRef(l.Name), cSwatch(l.Color), cNum(l.Issues), cNum(l.MRs))
158158 }
159159 tb.flush()
160160 })
internal/control/release.go +1 −1
@@ -297,7 +297,7 @@ func runReleaseList(c *Ctx, args []string) int {
297297 default:
298298 assets = fmt.Sprintf("%d assets", n)
299299 }
300 tb.row(cRef(d.Tag), cFlex(title), cText(assets), cAge(d.CreatedAt))
300 tb.row(cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cFlex(title), cText(assets), cAge(d.CreatedAt))
301301 }
302302 tb.flush()
303303 return
internal/control/repo.go +2 −2
@@ -363,7 +363,7 @@ func runRepoList(c *Ctx, args []string) int {
363363 return c.emitPage(p, ds, next, func(w io.Writer) {
364364 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
365365 for _, d := range ds {
366 cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)}
366 cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)}
367367 if d.Archived {
368368 cells = c.note(cells, 1, "[archived]", "archived")
369369 }
@@ -1089,7 +1089,7 @@ func runRepoSearch(c *Ctx, args []string) int {
10891089 return c.emit(ds, func(w io.Writer) {
10901090 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
10911091 for _, d := range ds {
1092 tb.row(cRef(d.Path), cState(d.Visibility), cFlex(d.Description))
1092 tb.row(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description))
10931093 }
10941094 tb.flush()
10951095 })
internal/control/search.go +7 −2
@@ -4,6 +4,7 @@ import (
44 "fmt"
55 "io"
66 "slices"
7 "strconv"
78
89 "gitbay.org/gitbay/internal/gitutil"
910 "gitbay.org/gitbay/internal/protocol"
@@ -118,12 +119,16 @@ func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) {
118119 switch r.Kind {
119120 case "repo":
120121 if c.Term.Cols > 0 {
121 tb.row(cText("repo"), cRef(r.Repo), cState(""), cFlex(r.Title))
122 tb.row(cText("repo"), cLink(r.Repo, c.siteURL(r.Repo)), cState(""), cFlex(r.Title))
122123 } else {
123124 tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title))
124125 }
125126 default:
126 tb.row(cText(r.Kind), cRef(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number)), cState(r.State), cFlex(r.Title))
127 page := "issues"
128 if r.Kind == "mr" {
129 page = "mrs"
130 }
131 tb.row(cText(r.Kind), cLink(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number), c.siteURL(r.Repo, page, strconv.FormatInt(r.Number, 10))), cState(r.State), cFlex(r.Title))
127132 }
128133 }
129134 tb.flush()
internal/control/table.go +17 −1
@@ -17,6 +17,7 @@ const (
1717 kindAge
1818 kindNum
1919 kindSize
20 kindSwatch
2021)
2122
2223// cell is one column of a table row. The kind decides colour, time
@@ -25,6 +26,7 @@ type cell struct {
2526 kind cellKind
2627 s string
2728 sgr string // colour for a kindText cell whose meaning is not its word
29 url string // the page a kindRef cell links to, when the terminal shows links
2830}
2931
3032func cRef(s string) cell { return cell{kind: kindRef, s: s} }
@@ -35,6 +37,14 @@ func cAge(ts string) cell { return cell{kind: kindAge, s: ts} }
3537func cNum(n int64) cell { return cell{kind: kindNum, s: strconv.FormatInt(n, 10)} }
3638func cSize(n int64) cell { return cell{kind: kindSize, s: strconv.FormatInt(n, 10)} }
3739
40// cLink is a reference that links to its page at a terminal that shows
41// links; url is a path on this instance or an absolute URL.
42func cLink(s, url string) cell { return cell{kind: kindRef, s: s, url: url} }
43
44// cSwatch is a label colour: a coloured dot before the hex at a terminal
45// with 24-bit colour.
46func cSwatch(hex string) cell { return cell{kind: kindSwatch, s: hex} }
47
3848// cMark is text coloured for what it says about the row rather than for
3949// its word: "2 failed" red, "review requested" yellow.
4050func cMark(s, sgr string) cell { return cell{kind: kindText, s: s, sgr: sgr} }
@@ -89,6 +99,10 @@ func (t *table) row(cs ...cell) {
8999 if n, err := strconv.ParseInt(cs[i].s, 10, 64); err == nil {
90100 cs[i].s = humanBytes(n)
91101 }
102 case kindSwatch:
103 if t.term.TrueColor && rgb(cs[i].s) != "" {
104 cs[i].s = "● " + cs[i].s
105 }
92106 }
93107 }
94108 t.rows = append(t.rows, cs)
@@ -258,7 +272,9 @@ func (t *table) joinRow(r []cell, line []string, widths []int) string {
258272 case kindState:
259273 s = t.term.paintState(s)
260274 case kindRef:
261 s = t.term.paint(sgrCyan, s)
275 s = t.term.link(r[i].url, t.term.paint(sgrCyan, s))
276 case kindSwatch:
277 s = t.term.swatch(s)
262278 default:
263279 s = t.term.paint(r[i].sgr, s)
264280 }
internal/control/table_test.go +22
@@ -189,3 +189,25 @@ func TestTableDropsEmptyColumns(t *testing.T) {
189189 t.Errorf("plain = %q", plain.String())
190190 }
191191}
192
193// A swatch's dot is counted in the column's width, so the next column
194// stays aligned.
195func TestTableSwatchAligned(t *testing.T) {
196 var b bytes.Buffer
197 tb := (&Ctx{Term: Term{Cols: 80, Color: true, TrueColor: true}}).table(&b, "NAME", "COLOR", "ISSUES")
198 tb.row(cRef("bug"), cSwatch("#cf222e"), cNum(3))
199 tb.row(cRef("none"), cSwatch(""), cNum(1))
200 tb.flush()
201 lines := strings.Split(stripSGR(b.String()), "\n")
202 col := func(line, sub string) int { return cells(line[:strings.Index(line, sub)]) }
203 if at := col(lines[0], "ISSUES"); at != col(lines[1], "3") || at != col(lines[2], "1") {
204 t.Errorf("ISSUES not aligned:\n%s", stripSGR(b.String()))
205 }
206 var plain bytes.Buffer
207 tb = (&Ctx{}).table(&plain, "NAME", "COLOR")
208 tb.row(cRef("bug"), cSwatch("#cf222e"))
209 tb.flush()
210 if plain.String() != "bug\t#cf222e\n" {
211 t.Errorf("plain = %q", plain.String())
212 }
213}
internal/control/term.go +43 −1
@@ -18,6 +18,10 @@ import (
1818type Term struct {
1919 Cols int
2020 Color bool
21 // TrueColor is 24-bit colour, for a label's own colour.
22 TrueColor bool
23 // Links is OSC 8 hyperlinks, for a reference's page.
24 Links bool
2125}
2226
2327// ParseTerm reads "<cols>[,<option>]...". Options it does not know are
@@ -32,10 +36,16 @@ func ParseTerm(v string) Term {
3236 }
3337 t := Term{Cols: n}
3438 for _, opt := range parts[1:] {
35 if opt == "color" {
39 switch opt {
40 case "color":
3641 t.Color = true
42 case "truecolor":
43 t.TrueColor = true
44 case "links":
45 t.Links = true
3746 }
3847 }
48 t.TrueColor = t.TrueColor && t.Color
3949 return t
4050}
4151
@@ -111,6 +121,38 @@ func stateColor(s string) string {
111121 return ""
112122}
113123
124// link makes s a hyperlink to url when the terminal shows them (OSC 8).
125// A url with a control byte in it is left out rather than sent.
126func (t Term) link(url, s string) string {
127 if !t.Links || url == "" || s == "" || strings.IndexFunc(url, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
128 return s
129 }
130 return "\x1b]8;;" + url + "\x1b\\" + s + "\x1b]8;;\x1b\\"
131}
132
133// rgb is the SGR sequence for a "#rrggbb" colour as a 24-bit
134// foreground, or "" when hex is not one.
135func rgb(hex string) string {
136 var r, g, b int
137 if len(hex) != 7 || hex[0] != '#' {
138 return ""
139 }
140 if n, err := fmt.Sscanf(hex[1:], "%02x%02x%02x", &r, &g, &b); err != nil || n != 3 {
141 return ""
142 }
143 return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)
144}
145
146// swatch paints the dot of a "● #rrggbb" label colour cell in that
147// colour; anything else is returned as it is.
148func (t Term) swatch(s string) string {
149 hex, ok := strings.CutPrefix(s, "● ")
150 if !ok || !t.TrueColor || rgb(hex) == "" {
151 return s
152 }
153 return t.paint(rgb(hex), "●") + " " + hex
154}
155
114156// paintState colours each word of a state cell: "private, archived"
115157// is two states, each in its own colour.
116158func (t Term) paintState(s string) string {
internal/control/term_test.go +22 −2
@@ -16,8 +16,9 @@ func TestParseTerm(t *testing.T) {
1616 "abc": {},
1717 "80,blink": {Cols: 80},
1818 "80,": {Cols: 80},
19 "80,truecolor,color": {Cols: 80, Color: true},
20 "80,color,links": {Cols: 80, Color: true},
19 "80,truecolor,color": {Cols: 80, Color: true, TrueColor: true},
20 "80,color,links": {Cols: 80, Color: true, Links: true},
21 "80,truecolor": {Cols: 80},
2122 "abc,color": {},
2223 "5000": {},
2324 }
@@ -199,3 +200,22 @@ func TestBuildLog(t *testing.T) {
199200 t.Errorf("escape reached the terminal: %q", got)
200201 }
201202}
203
204func TestLinkAndSwatch(t *testing.T) {
205 on := Term{Cols: 80, Color: true, TrueColor: true, Links: true}
206 if got := on.link("https://x.test/a", "a"); got != "\x1b]8;;https://x.test/a\x1b\\a\x1b]8;;\x1b\\" {
207 t.Errorf("link = %q", got)
208 }
209 if got := on.link("https://x.test/\x1b", "a"); got != "a" {
210 t.Errorf("control byte in url = %q", got)
211 }
212 if got := (Term{Cols: 80, Color: true}).link("https://x.test/a", "a"); got != "a" {
213 t.Errorf("without links = %q", got)
214 }
215 if got := on.swatch("● #cf222e"); got != "\x1b[38;2;207;34;46m●"+sgrReset+" #cf222e" {
216 t.Errorf("swatch = %q", got)
217 }
218 if got := on.swatch("● #nothex"); got != "● #nothex" {
219 t.Errorf("bad hex = %q", got)
220 }
221}