diff --git a/packages/bore/bore.1.md b/packages/bore/bore.1.md index 54ccfb9..3ad4bdd 100644 --- a/packages/bore/bore.1.md +++ b/packages/bore/bore.1.md @@ -60,16 +60,16 @@ The tunnel lasts as long as the command runs. Stop it with ctrl-c. bore sits between the tunnel and the service and reports what goes through. For an http tunnel that is a line per request: - 15:04:12 GET / 200 4ms 1.2 kB - 15:04:12 GET /static/app.css 200 1ms 14.0 kB - 15:04:19 POST /api/login 401 22ms 87 B + 15:04:12 GET / 200 4ms 1.2 kB + 15:04:13 GET /static/app.css 200 1ms 14 kB + 15:04:19 POST /api/login 401 22ms 87 B -Status codes are coloured by class. The lines are printed into normal scrollback with the tunnel's details pinned below them, so scrolling back through a session works as it would for any other command. Requests are only visible to something on the path, which is why the tunnel points at bore rather than at the service; **--no-inspect** removes the hop and the listing with it. +The columns are sized to the widest thing that can land in them, and a narrow terminal drops them from the right: the byte count first, then the duration, then the timestamp, so what happened and how it went survive to the end. Status codes are coloured by class. The lines are printed into normal scrollback with the tunnel's details pinned below them, so scrolling back through a session works as it would for any other command. Requests are only visible to something on the path, which is why the tunnel points at bore rather than at the service; **--no-inspect** removes the hop and the listing with it. tcp and udp carry bytes with no requests in them, so what gets reported is the shape of the traffic rather than its content: one line per conversation, with how long it lasted and how much went each way. - 15:04:12 tcp ↑ 1.4 kB ↓ 22.0 kB ok 1.2s 23.4 kB - 15:04:31 udp 18 datagrams ok 4.0s 2.1 kB + 15:04:12 tcp ↑ 1.4 kB ↓ 22.0 kB ok 1.2s 23 kB + 15:04:31 udp 18 datagrams ok 4.0s 2.1 kB A tcp conversation ends when either side hangs up. udp has no such thing, so a conversation is one source address until it goes quiet for thirty seconds. While conversations are held open the status line counts them, which for a database or an ssh session is most of what there is to say. diff --git a/packages/bore/go.mod b/packages/bore/go.mod index 0fa28d3..81f5536 100644 --- a/packages/bore/go.mod +++ b/packages/bore/go.mod @@ -8,6 +8,7 @@ require ( charm.land/lipgloss/v2 v2.0.5 github.com/BurntSushi/toml v1.6.0 github.com/charmbracelet/fang v1.0.0 + github.com/charmbracelet/x/term v0.2.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 ) @@ -22,7 +23,6 @@ require ( github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect diff --git a/packages/bore/inspect.go b/packages/bore/inspect.go index c850a16..a401c5c 100644 --- a/packages/bore/inspect.go +++ b/packages/bore/inspect.go @@ -61,16 +61,17 @@ type request struct { bytes int64 } -// render lays a request out in fixed columns, so a stream of them lines up -// however long the paths and timings are. -func (r request) render() string { - return fmt.Sprintf("%s %s %s %s %s", - dim(r.at.Format("15:04:05")), - methodStyle.Render(fmt.Sprintf("%-6s", r.method)), - padRight(r.path, 32), - statusStyle(r.status).Render(fmt.Sprintf("%3d", r.status)), - dim(fmt.Sprintf("%8s %9s", duration(r.took), size(r.bytes))), - ) +func (r request) render(width int) string { + return trafficLine{ + at: r.at, + verb: r.method, + verbStyle: methodStyle, + subject: r.path, + status: fmt.Sprint(r.status), + statusStyle: statusStyle(r.status), + took: r.took, + bytes: r.bytes, + }.render(width) } func startInspector(target int, ev events) (*inspector, error) { @@ -188,31 +189,3 @@ func statusStyle(status int) lipgloss.Style { } return labelStyle } - -// padRight keeps the status column in the same place whatever the path is. -func padRight(s string, width int) string { - if len(s) > width { - return s[:width-1] + "…" - } - return s + strings.Repeat(" ", width-len(s)) -} - -func duration(d time.Duration) string { - switch { - case d < time.Millisecond: - return fmt.Sprintf("%dµs", d.Microseconds()) - case d < time.Second: - return fmt.Sprintf("%dms", d.Milliseconds()) - } - return fmt.Sprintf("%.1fs", d.Seconds()) -} - -func size(bytes int64) string { - switch { - case bytes < 1024: - return fmt.Sprintf("%d B", bytes) - case bytes < 1024*1024: - return fmt.Sprintf("%.1f kB", float64(bytes)/1024) - } - return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) -} diff --git a/packages/bore/line.go b/packages/bore/line.go new file mode 100644 index 0000000..cbc53b9 --- /dev/null +++ b/packages/bore/line.go @@ -0,0 +1,169 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "charm.land/lipgloss/v2" +) + +// Traffic lines are printed one at a time into scrollback, so a column can +// never be resized once it is on screen. Every column therefore has a width +// picked from the widest thing that can ever land in it, and the formatters +// below are written to honour those widths rather than to be pretty in the +// average case. +// +// The subject takes whatever is left, and when there is not enough left the +// columns are dropped from the least useful in: a byte count you can live +// without, a duration you can usually guess, a timestamp you can read off the +// prompt. What survives to the end is the thing that happened and how it went. +const ( + timeWidth = 8 // 15:04:05 + verbWidth = 7 // OPTIONS, CONNECT, or a protocol name + statusWidth = 3 // 200, ok, err + tookWidth = 5 // 999µs … 59.9m + sizeWidth = 6 // 999 kB + subjectFloor = 16 + subjectCeil = 48 // a wide terminal should not push the numbers to the edge + subjectPlain = 32 // when there is no terminal to measure + gap = " " +) + +// trafficLine is one thing that went through the tunnel, laid out in columns +// shared by requests and flows so a tcp tunnel reads like an http one. +type trafficLine struct { + at time.Time + verb string + verbStyle lipgloss.Style + subject string + status string + statusStyle lipgloss.Style + took time.Duration + bytes int64 +} + +// render fits the line to the terminal. A width of zero means we are not +// drawing to one, so nothing is dropped. +func (l trafficLine) render(width int) string { + showTime, showTook, showSize := true, true, true + + // Fixed cost of everything but the subject and its trailing gap. + fixed := func() int { + n := verbWidth + len(gap) + statusWidth + for _, col := range []struct { + shown bool + width int + }{{showTime, timeWidth}, {showTook, tookWidth}, {showSize, sizeWidth}} { + if col.shown { + n += col.width + len(gap) + } + } + return n + } + + subject := subjectPlain + if width > 0 { + // Give up columns in order until the subject has room to say anything. + for _, drop := range []*bool{&showSize, &showTook, &showTime} { + if width-fixed()-len(gap) >= subjectFloor { + break + } + *drop = false + } + // The floor decides when to drop a column, not how narrow the subject + // may get: past that point the terminal is narrower than the line can + // be, and running over would wrap every line into two. + subject = min(max(width-fixed()-len(gap), 0), subjectCeil) + } + + var out strings.Builder + if showTime { + out.WriteString(dim(l.at.Format("15:04:05")) + gap) + } + out.WriteString(l.verbStyle.Render(padRight(l.verb, verbWidth)) + gap) + if subject > 0 { + out.WriteString(padRight(l.subject, subject) + gap) + } + out.WriteString(l.statusStyle.Render(padLeft(l.status, statusWidth))) + if showTook { + out.WriteString(gap + dim(padLeft(duration(l.took), tookWidth))) + } + if showSize { + out.WriteString(gap + dim(padLeft(size(l.bytes), sizeWidth))) + } + return out.String() +} + +// duration fits five columns whatever it is handed. A request is over in +// milliseconds but a tcp conversation can last all afternoon, and both have to +// sit in the same slot. It gives up at a thousand hours, which is six weeks. +func duration(d time.Duration) string { + switch { + case d < time.Millisecond: + return fmt.Sprintf("%dµs", d.Microseconds()) + case d < time.Second: + return fmt.Sprintf("%dms", d.Milliseconds()) + case d < time.Minute: + return fmt.Sprintf("%.1fs", d.Seconds()) + case d < time.Hour: + return fmt.Sprintf("%.1fm", d.Minutes()) + case d < 10*time.Hour: + return fmt.Sprintf("%.1fh", d.Hours()) + } + return fmt.Sprintf("%dh", int(d.Hours())) +} + +// size fits six columns. The units are SI, so the divisor is 1000: calling +// 1024 bytes a kilobyte was always the other way round, and it is what made +// the old formatter print a ten-column "1023.9 kB" into a nine-column slot. +func size(bytes int64) string { + units := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"} + value, unit := float64(bytes), 0 + for value >= 1000 && unit < len(units)-1 { + value /= 1000 + unit++ + } + // A hair under the next unit still rounds up into four digits, so step + // again rather than let "1000 kB" widen the column. + if value >= 999.5 && unit < len(units)-1 { + value /= 1000 + unit++ + } + + switch { + case unit == 0: + return fmt.Sprintf("%d B", bytes) + case value < 10: + return fmt.Sprintf("%.1f %s", value, units[unit]) + } + return fmt.Sprintf("%.0f %s", value, units[unit]) +} + +// padRight keeps a column in the same place whatever is in it, cutting what +// will not fit. It measures printed width rather than bytes, since a µ or an +// arrow is one column and two bytes. +func padRight(s string, width int) string { + if n := lipgloss.Width(s); n > width { + return truncate(s, width-1) + "…" + } + return s + strings.Repeat(" ", width-lipgloss.Width(s)) +} + +func padLeft(s string, width int) string { + if n := lipgloss.Width(s); n < width { + return strings.Repeat(" ", width-n) + s + } + return s +} + +func truncate(s string, width int) string { + var out strings.Builder + for _, r := range s { + if lipgloss.Width(out.String()+string(r)) > width { + break + } + out.WriteRune(r) + } + return out.String() +} diff --git a/packages/bore/line_test.go b/packages/bore/line_test.go new file mode 100644 index 0000000..19e341b --- /dev/null +++ b/packages/bore/line_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "strings" + "testing" + "time" + + "charm.land/lipgloss/v2" +) + +// The columns are only in the same place on every line if nothing can ever +// overflow its slot, and both formatters have inputs that used to. +func TestDurationNeverOutgrowsItsColumn(t *testing.T) { + for _, d := range []time.Duration{ + 0, + 999 * time.Microsecond, + time.Millisecond, + 999 * time.Millisecond, + 59*time.Second + 990*time.Millisecond, + time.Minute, + 59*time.Minute + 59*time.Second, + time.Hour, + 9*time.Hour + 59*time.Minute, + 36 * time.Hour, + 200 * time.Hour, + } { + if got := duration(d); lipgloss.Width(got) > tookWidth { + t.Errorf("duration(%s) = %q, %d wide, want at most %d", d, got, lipgloss.Width(got), tookWidth) + } + } +} + +func TestSizeNeverOutgrowsItsColumn(t *testing.T) { + for _, b := range []int64{ + 0, 1, 999, 1000, 1023, 999_499, 999_500, 1_000_000, + 999_999_999, 1 << 40, 1 << 50, 1<<63 - 1, + } { + if got := size(b); lipgloss.Width(got) > sizeWidth { + t.Errorf("size(%d) = %q, %d wide, want at most %d", b, got, lipgloss.Width(got), sizeWidth) + } + } +} + +func TestSizeCountsInThousands(t *testing.T) { + // The units say kB, not KiB, so the divisor has to match what is written. + for _, c := range []struct { + bytes int64 + want string + }{ + {999, "999 B"}, + {1000, "1.0 kB"}, + {829, "829 B"}, + {1_200_000, "1.2 MB"}, + } { + if got := size(c.bytes); got != c.want { + t.Errorf("size(%d) = %q, want %q", c.bytes, got, c.want) + } + } +} + +// A line has to fit the terminal it is printed into, whatever is in it. +func TestTrafficLineFitsTheTerminal(t *testing.T) { + line := trafficLine{ + at: time.Now(), + verb: "OPTIONS", + verbStyle: lipgloss.NewStyle(), + subject: strings.Repeat("/very-long-path", 20), + status: "200", + statusStyle: lipgloss.NewStyle(), + took: 90 * time.Minute, + bytes: 1<<62 - 1, + } + // Narrower than the verb and status columns is not a terminal anyone has. + for width := verbWidth + len(gap) + statusWidth; width <= 200; width++ { + if got := lipgloss.Width(line.render(width)); got > width { + t.Fatalf("a %d wide terminal got a %d wide line", width, got) + } + } +} + +// Narrow terminals give up columns rather than the subject, in order of what +// can most be done without. +func TestNarrowLinesDropColumnsInOrder(t *testing.T) { + line := trafficLine{ + at: time.Date(2026, 8, 8, 21, 34, 28, 0, time.UTC), + verb: "GET", + verbStyle: lipgloss.NewStyle(), + subject: "/api/models/chat", + status: "200", + statusStyle: lipgloss.NewStyle(), + took: 886 * time.Microsecond, + bytes: 829, + } + + full := line.render(100) + for _, want := range []string{"21:34:28", "886µs", "829 B", "/api/models/chat"} { + if !strings.Contains(full, want) { + t.Errorf("a wide terminal should show %q: %q", want, full) + } + } + + if got := line.render(47); strings.Contains(got, "829 B") || !strings.Contains(got, "886µs") { + t.Errorf("the byte count should go before the duration: %q", got) + } + if got := line.render(42); strings.Contains(got, "886µs") || !strings.Contains(got, "21:34:28") { + t.Errorf("the duration should go before the time: %q", got) + } + if got := line.render(30); strings.Contains(got, "21:34:28") { + t.Errorf("the time should go last: %q", got) + } + // Whatever else goes, what happened and how it went stay. + narrow := line.render(30) + for _, want := range []string{"GET", "200", "/api"} { + if !strings.Contains(narrow, want) { + t.Errorf("the narrowest line should still show %q: %q", want, narrow) + } + } +} + +// A µ is two bytes and one column, and the subject can hold arrows. +func TestPaddingMeasuresColumnsNotBytes(t *testing.T) { + if got := lipgloss.Width(padRight("↑ 85 B ↓ 1.1 kB", 20)); got != 20 { + t.Errorf("padRight produced %d columns, want 20", got) + } + if got := lipgloss.Width(padRight("↑ 85 B ↓ 1.1 kB", 10)); got != 10 { + t.Errorf("truncation produced %d columns, want 10", got) + } + if got := lipgloss.Width(padLeft("886µs", tookWidth)); got != tookWidth { + t.Errorf("padLeft produced %d columns, want %d", got, tookWidth) + } +} diff --git a/packages/bore/stream.go b/packages/bore/stream.go index 3636b14..9704387 100644 --- a/packages/bore/stream.go +++ b/packages/bore/stream.go @@ -32,18 +32,21 @@ type flow struct { // render uses the same columns as a request line, so a tcp tunnel reads like an // http one even though the middle of it says something different. -func (f flow) render() string { - status, style := " ok", labelStyle +func (f flow) render(width int) string { + status, style := "ok", labelStyle if f.failed { status, style = "err", failStyle } - return fmt.Sprintf("%s %s %s %s %s", - dim(f.at.Format("15:04:05")), - methodStyle.Render(fmt.Sprintf("%-6s", f.protocol)), - padRight(f.detail, 32), - style.Render(status), - dim(fmt.Sprintf("%8s %9s", duration(f.took), size(f.up+f.down))), - ) + return trafficLine{ + at: f.at, + verb: f.protocol, + verbStyle: methodStyle, + subject: f.detail, + status: status, + statusStyle: style, + took: f.took, + bytes: f.up + f.down, + }.render(width) } // startStream puts a counter in front of a tcp or udp port and returns the port diff --git a/packages/bore/tui.go b/packages/bore/tui.go index 326a64f..f34a86f 100644 --- a/packages/bore/tui.go +++ b/packages/bore/tui.go @@ -24,6 +24,7 @@ type tunnelUI struct { // tcp, callers over udp. Counting them all as "requests" would be a small // lie that makes a tcp tunnel read like an http one. noun string + width int count int open int bytes int64 @@ -52,6 +53,11 @@ func (m tunnelUI) Init() tea.Cmd { return nil } func (m tunnelUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.WindowSizeMsg: + // Lines are printed into scrollback and never redrawn, so the width + // matters at the moment each one is rendered, not at the end. + m.width = msg.Width + case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "q", "esc": @@ -63,12 +69,12 @@ func (m tunnelUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.count++ m.bytes += msg.bytes // Printed rather than stored: the terminal keeps the history. - return m, tea.Println(request(msg).render()) + return m, tea.Println(request(msg).render(m.width)) case flowMsg: m.count++ m.bytes += msg.up + msg.down - return m, tea.Println(flow(msg).render()) + return m, tea.Println(flow(msg).render(m.width)) case openMsg: m.open += int(msg) diff --git a/packages/bore/tunnel.go b/packages/bore/tunnel.go index 2c1e3f5..7076c82 100644 --- a/packages/bore/tunnel.go +++ b/packages/bore/tunnel.go @@ -284,10 +284,14 @@ func start(root context.Context, t *Tunnel, opts tunnelOptions) error { // mode prints it as it happens. live := !opts.noInspect && !opts.verbose && stdoutIsTerminal() + // Without the view there is nothing repainting on resize, so the width is + // read once and lines are cut to whatever the terminal was then. + plainWidth := terminalWidth() + var program *tea.Program ev := events{ - request: func(r request) { lipgloss.Println(r.render()) }, - flow: func(f flow) { lipgloss.Println(f.render()) }, + request: func(r request) { lipgloss.Println(r.render(plainWidth)) }, + flow: func(f flow) { lipgloss.Println(f.render(plainWidth)) }, notice: sec.notice, header: func(row headerRow) { sec.row(row.label, row.value) }, open: func(int) {}, diff --git a/packages/bore/ui.go b/packages/bore/ui.go index d0f84b6..4fabd02 100644 --- a/packages/bore/ui.go +++ b/packages/bore/ui.go @@ -8,6 +8,7 @@ import ( "charm.land/huh/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/term" ) // ANSI 0-15 only, so the terminal's own theme picks the shades. @@ -95,6 +96,16 @@ func link(url string) string { return "\x1b]8;;" + url + "\x1b\\" + url + "\x1b]8;;\x1b\\" } +// terminalWidth is what we have to fit into, or zero when we are not drawing +// to a terminal at all and can print in full. +func terminalWidth() int { + width, _, err := term.GetSize(os.Stdout.Fd()) + if err != nil { + return 0 + } + return width +} + func stdoutIsTerminal() bool { info, err := os.Stdout.Stat() return err == nil && info.Mode()&os.ModeCharDevice != 0