Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ The CLI operates as a wrapper around a legacy PHP CLI:
- JWT handling and OAuth2 flow
- Custom transport for API authentication

**Table Output**: `internal/table/`
- Renders tables with the same `--format`, `--columns` and `--no-header` options as the legacy CLI's `Table` service
- The `csv`, `tsv` and `plain` formats match the legacy output, with ANSI escape sequences removed
- The `table` format wraps cells to the terminal width (a port of the legacy `AdaptiveTable`), and supports ANSI-styled cells

**Project Initialization**: `internal/init/`
- AI-powered project configuration generation
- Integrates with whatsun library for codebase analysis
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/Masterminds/semver/v3 v3.5.0
github.com/alecthomas/chroma/v2 v2.27.0
github.com/briandowns/spinner v1.23.2
github.com/charmbracelet/x/ansi v0.11.7
github.com/fatih/color v1.19.0
github.com/go-chi/chi/v5 v5.3.2
github.com/go-playground/validator/v10 v10.30.5
Expand Down Expand Up @@ -50,7 +51,6 @@ require (
github.com/buger/jsonparser v1.1.2 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
Expand Down
16 changes: 8 additions & 8 deletions integration-tests/activity_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,14 @@ func TestActivityList(t *testing.T) {
`, f.Run("act", "-p", projectID, "-e", "."))

assertTrimmed(t, `
+------+----------------------+---------------------------------+----------+----------+---------+----------------+
| ID | Created | Description | Progress | State | Result | Environment(s) |
+------+----------------------+---------------------------------+----------+----------+---------+----------------+
| act1 | 2014-04-01T10:00:00+ | Mock User created variable X on | 100% | complete | success | main |
| | 00:00 | environment main | | | | |
| act2 | 2014-04-01T09:00:00+ | Mock User created variable X | 100% | complete | success | |
| | 00:00 | | | | | |
+------+----------------------+---------------------------------+----------+----------+---------+----------------+
+------+-------------------------+---------------------------------+----------+----------+---------+----------------+
| ID | Created | Description | Progress | State | Result | Environment(s) |
+------+-------------------------+---------------------------------+----------+----------+---------+----------------+
| act1 | 2014-04-01T10:00:00+00: | Mock User created variable X on | 100% | complete | success | main |
| | 00 | environment main | | | | |
| act2 | 2014-04-01T09:00:00+00: | Mock User created variable X | 100% | complete | success | |
| | 00 | | | | | |
+------+-------------------------+---------------------------------+----------+----------+---------+----------------+
`, f.Run("act", "-p", projectID, "--all", "--limit", "20"))

assertTrimmed(t, "complete", f.Run("act:get", "-p", projectID, "-e", ".", "act1", "-P", "state"))
Expand Down
16 changes: 8 additions & 8 deletions integration-tests/environment_deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,13 @@ func TestEnvironmentDeploy(t *testing.T) {
})

assertTrimmed(t, `
+------+-------------------------+---------------------------------------------+-----------------------------+---------+
| ID | Created | Description | Type | Result |
+------+-------------------------+---------------------------------------------+-----------------------------+---------+
| act2 | 2014-04-02T10:00:00+00: | Mock User created variable X on environment | environment.variable.create | success |
| | 00 | main | | |
| act1 | 2014-04-01T10:00:00+00: | Mock User pushed to main | environment.push | success |
| | 00 | | | |
+------+-------------------------+---------------------------------------------+-----------------------------+---------+
+------+--------------------------+---------------------------------+-----------------------------+---------+
| ID | Created | Description | Type | Result |
+------+--------------------------+---------------------------------+-----------------------------+---------+
| act2 | 2014-04-02T10:00:00+00:0 | Mock User created variable X on | environment.variable.create | success |
| | 0 | environment main | | |
| act1 | 2014-04-01T10:00:00+00:0 | Mock User pushed to main | environment.push | success |
| | 0 | | | |
+------+--------------------------+---------------------------------+-----------------------------+---------+
`, f.Run("env:deploy", "-p", projectID, "-e", "main"))
}
261 changes: 261 additions & 0 deletions internal/table/adaptive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
package table

import (
"os"
"regexp"
"slices"
"strconv"
"strings"

"github.com/charmbracelet/x/ansi"
"golang.org/x/term"
)

// Words up to maxUnbrokenWidth are not broken when wrapping if the table can fit, and words up to
// minUnbrokenWidth are not broken even if it does not.
const (
maxUnbrokenWidth = 20
minUnbrokenWidth = 10
)

// terminalWidth returns the width of the terminal, from $COLUMNS or the first standard stream that is a terminal.
func terminalWidth() int {
if w, err := strconv.Atoi(strings.TrimSpace(os.Getenv("COLUMNS"))); err == nil && w > 0 {
return w
}
for _, f := range []*os.File{os.Stdout, os.Stderr, os.Stdin} {
if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 {
return w
}
}
return 80
}

// maxColumnWidths finds the maximum width of each column's content, so that the table fits in maxWidth.
//
// This is similar to a web browser's automatic table layout, and matches the legacy CLI's
// AdaptiveTable::getMaxColumnWidths(). Each column has a maximum width (its widest cell) and a minimum width
// (its longest word, up to maxUnbrokenWidth, or less if needed to fit). If the maximum widths do not fit, each
// column gets its minimum width plus a share of the remaining space, in proportion to the difference between its
// maximum and minimum.
// The rows before bodyStart are header rows, which are not wrapped.
func maxColumnWidths(rows [][][]string, bodyStart int, noWrap []bool, maxWidth int) []int {
count := len(rows[0])
maxWidths := make([]int, count)
fixedWidths := make([]int, count)
wordWidths := make([]int, count)
for i, row := range rows {
for j, lines := range row {
w := cellWidth(lines)
maxWidths[j] = max(maxWidths[j], w)
if (j < len(noWrap) && noWrap[j]) || i < bodyStart {
fixedWidths[j] = max(fixedWidths[j], w)
} else {
// A cell at its full width is not wrapped, so its minimum is no more than that.
wordWidths[j] = max(wordWidths[j], min(longestWordWidth(lines), w))
}
}
}

available := maxWidth - (count + 1) - count*2
maxTotal := sum(maxWidths)
if maxTotal <= available {
return maxWidths
}

// The minimum column width is the width of the longest word, capped at maxUnbrokenWidth.
// The cap is reduced, down to minUnbrokenWidth, until the minimum widths fit.
minWidths := make([]int, count)
minTotal := 0
for limit := maxUnbrokenWidth; ; limit-- {
for j := range minWidths {
minWidths[j] = max(fixedWidths[j], min(wordWidths[j], limit))
}
minTotal = sum(minWidths)
if minTotal <= available || limit <= minUnbrokenWidth {
break
}
}
if minTotal >= available {
return minWidths
}

// Share the extra space in proportion to each column's flexibility (its maximum minus its minimum width).
// The remainder is given to the columns with the largest fractional parts, in column order for ties.
extra, flexTotal := available-minTotal, maxTotal-minTotal
widths := make([]int, count)
remainders := make([]int, count)
for j := range widths {
share := (maxWidths[j] - minWidths[j]) * extra
widths[j] = minWidths[j] + share/flexTotal
remainders[j] = share % flexTotal
}
order := make([]int, count)
for j := range order {
order[j] = j
}
slices.SortStableFunc(order, func(a, b int) int { return remainders[b] - remainders[a] })
for _, j := range order[:available-sum(widths)] {
Comment thread
pjcdawkins marked this conversation as resolved.
widths[j]++
}
return widths
}

func sum(s []int) int {
total := 0
for _, v := range s {
total += v
}
return total
}

// longestWordWidth returns the display width of the longest whitespace-separated word in a cell,
// plus its indentation, which is repeated on each wrapped line.
func longestWordWidth(lines []string) int {
w := 0
for _, line := range lines {
for _, word := range strings.Fields(ansi.Strip(line)) {
w = max(w, ansi.StringWidth(word))
}
}
plain := ansi.Strip(strings.Join(lines, "\n"))
return w + len(plain) - len(strings.TrimLeft(plain, " "))
}

// cellWidth returns the display width of the longest line in a cell.
func cellWidth(lines []string) int {
w := 0
for _, line := range lines {
w = max(w, ansi.StringWidth(line))
}
return w
}

// wrapCell word-wraps a cell's lines to fit the width, keeping any left indentation.
func wrapCell(lines []string, width int) []string {
contents := strings.Join(lines, "\n")
trimmed := strings.TrimLeft(contents, " ")
indent := contents[:len(contents)-len(trimmed)]
wrapped := strings.Split(wordwrap(trimmed, max(width-len(indent), 1)), "\n")
if indent != "" {
for i, line := range wrapped {
wrapped[i] = indent + line
}
}
return wrapped
}

// wordwrap wraps text to the width, like PHP's wordwrap($text, $width, "\n", true).
// It is a port of PHP's implementation, measuring display width instead of bytes and keeping ANSI sequences.
func wordwrap(text string, width int) string {
var (
units []string
widths []int
state byte
)
for text != "" {
seq, w, n, newState := ansi.DecodeSequence(text, state, nil)
units, widths, state, text = append(units, seq), append(widths, w), newState, text[n:]
}
pos := make([]int, len(units)+1)
for i, w := range widths {
pos[i+1] = pos[i] + w
}

var b strings.Builder
write := func(from, to int, lineBreak bool) {
b.WriteString(strings.Join(units[from:to], ""))
if lineBreak {
b.WriteString("\n")
}
}
lastStart, lastSpace := 0, 0
for cur, unit := range units {
// Zero-width ANSI sequences never cause a break.
if widths[cur] == 0 && unit != "\n" {
continue
}
// The line is full, as in PHP, or the current unit would overflow it (if it is wide).
lineWidth := pos[cur] - pos[lastStart]
over := lineWidth >= width || lineWidth+widths[cur] > width
switch {
case unit == "\n" && cur+1 < len(units):
// Keep existing line breaks.
write(lastStart, cur+1, false)
lastStart, lastSpace = cur+1, cur+1
case unit == " ":
// Break at a space at the line boundary.
if lineWidth >= width {
write(lastStart, cur, true)
lastStart = cur + 1
}
lastSpace = cur
case over && lastStart >= lastSpace && cur > lastStart:
// Cut a word that is too long.
write(lastStart, cur, true)
lastStart, lastSpace = cur, cur
case over && lastStart < lastSpace:
// Break at the last space.
write(lastStart, lastSpace, true)
lastStart, lastSpace = lastSpace+1, lastSpace+1
}
}
write(lastStart, len(units), false)
return b.String()
}

var (
sgrRegex = regexp.MustCompile(`\x1b\[([0-9;:]*)m`)
hyperlinkRegex = regexp.MustCompile(`\x1b\]8;[^;\x07\x1b]*;([^\x07\x1b]*)(?:\x07|\x1b\\)`)
)

const hyperlinkClose = "\x1b]8;;\x1b\\"

// isolateStyles closes any ANSI style (SGR) or hyperlink (OSC 8) still open at the end of each line,
// and re-opens it on the next line. This stops them from leaking into the table's borders and other cells.
func isolateStyles(lines []string) []string {
Comment thread
upsun-dispatch[bot] marked this conversation as resolved.
style, link := "", ""
out := make([]string, len(lines))
for i, line := range lines {
out[i] = style + link + line
for _, m := range sgrRegex.FindAllStringSubmatch(line, -1) {
if sgrResets(m[1]) {
style = ""
}
if m[1] != "" && m[1] != "0" {
style += m[0]
}
}
for _, m := range hyperlinkRegex.FindAllStringSubmatch(line, -1) {
link = ""
if m[1] != "" {
link = m[0]
}
}
if link != "" {
out[i] += hyperlinkClose
}
if style != "" {
out[i] += "\x1b[0m"
}
}
return out
}

// sgrResets returns whether SGR parameters include a reset, skipping the arguments of extended colors.
func sgrResets(params string) bool {
parts := strings.Split(params, ";")
for i := 0; i < len(parts); i++ {
switch parts[i] {
case "", "0":
return true
case "38", "48", "58":
if i+1 < len(parts) && parts[i+1] == "5" {
i += 2
} else if i+1 < len(parts) && parts[i+1] == "2" {
i += 4
}
}
}
return false
}
Loading
Loading