feat: fit list tables to the terminal and write TSV when piped

Lists had no header, wrapped every row once a title was longer than
the terminal, and were space-padded when piped, so cut -f and awk -F'\t'
could not split them and empty cells collapsed.

terminal.Table now takes column specs and a Display. On a terminal it
prints an uppercase header, aligns columns two spaces apart and shrinks
flexible columns, widest first, to Streams.Width (the terminal, then
$COLUMNS, then 80), cutting by display width with "…". States use the
terminal's ANSI 16 colors, hex colors fall back to the 256-color palette
unless COLORTERM advertises 24-bit, and OSC 8 links appear only on the
first column, independently of color (TEA_HYPERLINKS=auto|always|never).
Piped tables are tab-separated without header, color or links, and times
are RFC3339 UTC instead of "about 2 hours ago".

terminal.View lays out single resources (title, badges, metadata, fields,
body, footer URL) and renders markdown bodies with glamour on a terminal
only (TEA_MARKDOWN=raw keeps them raw). Shared formatters cover relative
times, timestamps, sizes, durations and status icons.

In the app, writeTable and writeView are the new list and view writers:
a "Showing N of M …" context line, an empty-state message on stderr and
the next-page hint. writeList, writeItem and batch keep working and now
render through the new table: tab-separated when piped, truncated at the
widest column on a terminal, with headers for issue list and pr list.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 22:43:10 +02:00
1 parent 1c9cc32d28
commit 55574a9fd0
17 files changed
+1253 -103

No files matched your search

+115 -22
View File
@@ -4,47 +4,140 @@
package terminal
import (
"strings"
"testing"
"time"
"github.com/mattn/go-runewidth"
"github.com/stretchr/testify/require"
)
func TestTable(t *testing.T) {
now := time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC)
tty := Display{TTY: true, Now: now}
issues := []Column{{Header: "ID", Prefix: "#"}, {Header: "Title", Flex: true}, {Header: "Labels", Flex: true}, {Header: "Updated"}}
issueRows := [][]Cell{
{{Text: "12", Link: "https://example.org/12"}, {Text: "Emoji in title 🚀 works?"}, {}, {Time: now.Add(-2 * time.Hour)}},
{{Text: "1", Link: "https://example.org/1"}, {Text: "Login fails with special characters in password"}, {Text: "bug, priority/high"}, {Time: now.Add(-90 * time.Minute)}},
}
for _, tc := range []struct {
name string
rows [][]Cell
color bool
want string
name string
columns []Column
rows [][]Cell
d Display
want string
}{
{"empty", nil, false, ""},
{"aligns columns", [][]Cell{Row("7", "Fix login", "open"), Row("12", "Crash", "closed")}, false, "7 Fix login open\n12 Crash closed\n"},
{"counts runes, not bytes", [][]Cell{Row("1", "äöü", "x"), Row("2", "abcd", "y")}, false, "1 äöü x\n2 abcd y\n"},
{"counts wide runes twice", [][]Cell{Row("1", "修复", "x"), Row("2", "abcd", "y")}, false, "1 修复 x\n2 abcd y\n"},
{"trims empty trailing cells", [][]Cell{Row("1", "bug", ""), Row("22", "feature", "new work")}, false, "1 bug\n22 feature new work\n"},
{"pads ragged rows", [][]Cell{Row("1"), Row("2", "two")}, false, "1\n2 two\n"},
{"empty", issues, nil, tty, ""},
{"aligns legacy rows without header", nil, [][]Cell{Row("7", "Fix login", "open"), Row("12", "Crash", "closed")}, tty, "7 Fix login open\n12 Crash closed\n"},
{"counts runes, not bytes", nil, [][]Cell{Row("1", "äöü", "x"), Row("2", "abcd", "y")}, tty, "1 äöü x\n2 abcd y\n"},
{"counts wide runes twice", nil, [][]Cell{Row("1", "修复", "x"), Row("2", "abcd", "y")}, tty, "1 修复 x\n2 abcd y\n"},
{"trims empty trailing cells", nil, [][]Cell{Row("1", "bug", ""), Row("22", "feature", "new work")}, tty, "1 bug\n22 feature new work\n"},
{"pads ragged rows", nil, [][]Cell{Row("1"), Row("2", "two")}, tty, "1\n2 two\n"},
{
"strips hostile title",
[][]Cell{Row("1", "a\x1b]52;c;cHduZWQ=\x07b\x1b[2J\u009b31m‮txt.exe\r\ntail\tend", "open")},
false,
"1 a]52;c;cHduZWQ=b[2J31mtxt.exe tail end open\n",
"heads columns and formats cells for people",
issues, issueRows, tty,
"ID TITLE LABELS UPDATED\n" +
"#12 Emoji in title 🚀 works? about 2 hours ago\n" +
"#1 Login fails with special characters in password bug, priority/high about 1 hour ago\n",
},
{"ignores styles without color", [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}}, false, "7 bug open\n"},
{
"styles after alignment",
[][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}, Row("12", "feature", "closed")},
true,
"\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ \x1b[38;2;255;0;0mbug\x1b[0m \x1b[1mopen\x1b[0m\n12 feature closed\n",
"shrinks the widest flexible column first",
issues, issueRows,
Display{TTY: true, Now: now, Width: 72},
"ID TITLE LABELS UPDATED\n" +
"#12 Emoji in title 🚀 works? about 2 hours ago\n" +
"#1 Login fails with special ch… bug, priority/high about 1 hour ago\n",
},
{
"keeps flexible columns at their floor",
issues, issueRows,
Display{TTY: true, Now: now, Width: 40},
"ID TITLE LABELS UPDATED\n" +
"#12 Emoji i… about 2 hours ago\n" +
"#1 Login f… bug, pr… about 1 hour ago\n",
},
{
"cuts wide runes by display width",
[]Column{{Header: "Title", Flex: true}, {Header: "By"}},
[][]Cell{Row("支持中文路径 (support CJK paths)", "alice")},
Display{TTY: true, Width: 20},
"TITLE BY\n支持中文路径… alice\n",
},
{
"writes tab-separated values when piped",
issues, issueRows,
Display{Width: 40, Color: true, Links: true},
"12\tEmoji in title 🚀 works?\t\t2026-10-11T18:00:00Z\n" +
"1\tLogin fails with special characters in password\tbug, priority/high\t2026-10-11T18:30:00Z\n",
},
{
"keeps columns to their output",
[]Column{{Header: "ID"}, {Header: "State", PipedOnly: true}, {Header: "Link", TTYOnly: true}},
[][]Cell{Row("1", "open", "→")},
tty,
"ID LINK\n1 →\n",
},
{
"keeps piped-only columns when piped",
[]Column{{Header: "ID"}, {Header: "State", PipedOnly: true}, {Header: "Link", TTYOnly: true}},
[][]Cell{Row("1", "open", "→")},
Display{},
"1\topen\n",
},
{
"strips hostile text in both outputs",
nil,
[][]Cell{Row("1", "a\x1b]52;c;cHduZWQ=\x07b\x1b[2J\u009b31m‮txt.exe\r\ntail\tend", "open")},
Display{},
"1\ta]52;c;cHduZWQ=b[2J31mtxt.exe tail end\topen\n",
},
{"ignores styles without color", nil, [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}}, tty, "7 bug open\n"},
{
"styles after alignment and links only the first column",
[]Column{{Header: "ID"}, {Header: "Name"}, {Header: "State"}},
[][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000", Link: "https://example.org/bug"}, {Text: "open", Color: "green", Bold: true}}, Row("12", "feature", "closed")},
Display{TTY: true, Color: true, Links: true},
"\x1b[90mID\x1b[0m \x1b[90mNAME\x1b[0m \x1b[90mSTATE\x1b[0m\n" +
"\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ \x1b[38;5;196mbug\x1b[0m \x1b[1m\x1b[32mopen\x1b[0m\x1b[0m\n" +
"12 feature closed\n",
},
{
"links without color",
nil,
[][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Muted: true}}},
Display{TTY: true, Links: true},
"\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ bug\n",
},
{
"writes 24-bit color on true-color terminals",
nil,
[][]Cell{{{Text: "bug", Color: "#ff0000"}, {Text: "old", Muted: true}}},
Display{TTY: true, Color: true, TrueColor: true},
"\x1b[38;2;255;0;0mbug\x1b[0m \x1b[90mold\x1b[0m\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, Table(tc.rows, tc.color))
require.Equal(t, tc.want, Table(tc.columns, tc.rows, tc.d))
})
}
}
func TestTableFitsWidthWithWideRunes(t *testing.T) {
titles := []string{"Emoji in title 🚀 works?", "支持中文路径 (support CJK paths)", strings.Repeat("long ", 40)}
rows := make([][]Cell, len(titles))
for i, title := range titles {
rows[i] = Row("#1", title, "alice")
}
for _, width := range []int{30, 50, 80} {
for _, line := range strings.Split(strings.TrimSuffix(Table([]Column{{}, {Flex: true}, {}}, rows, Display{TTY: true, Width: width}), "\n"), "\n") {
require.LessOrEqual(t, runewidth.StringWidth(line), width, line)
}
}
}
func TestRecord(t *testing.T) {
record := Record{
Heading: []Cell{{Text: "#7", Bold: true}, {Text: "Crash\x1b[2J\nnow"}, {Text: "bob\u202e"}},
Heading: []Cell{{Text: "#7", Bold: true}, {Text: "Crash\x1b[2J\nnow"}, {Text: "bob‮"}},
Lines: []string{"Steps:\r\n\x1b]52;c;aGk=\x07run\n", "https://example.org/7"},
}
require.Equal(t, "#7 Crash[2J now bob\nSteps:\n]52;c;aGk=run\n\nhttps://example.org/7\n", record.Render(false))
@@ -58,7 +151,7 @@ func TestSafeText(t *testing.T) {
{"drops lone carriage returns", "safe\rFAKE", "safeFAKE", "safeFAKE"},
{
"strips hostile body",
"Steps:\r\n\x1b]52;c;cHduZWQ=\x07run\x1b[2J\u009b31m\u202e\u200bit\x7f\n\x00done",
"Steps:\r\n\x1b]52;c;cHduZWQ=\x07run\x1b[2J\u009b31m‮​it\x7f\n\x00done",
"Steps:\n]52;c;cHduZWQ=run[2J31mit\ndone",
"Steps: ]52;c;cHduZWQ=run[2J31mit done",
},