summaryrefslogtreecommitdiff
path: root/internal/termprint
diff options
context:
space:
mode:
Diffstat (limited to 'internal/termprint')
-rw-r--r--internal/termprint/columns.go207
1 files changed, 207 insertions, 0 deletions
diff --git a/internal/termprint/columns.go b/internal/termprint/columns.go
new file mode 100644
index 0000000..b4d30bc
--- /dev/null
+++ b/internal/termprint/columns.go
@@ -0,0 +1,207 @@
+package termprint
+
+import (
+ "io"
+ "os"
+ "strings"
+ "sync"
+
+ "github.com/mattn/go-runewidth"
+ "golang.org/x/term"
+)
+
+// ColumnPrinter streams provider output in side-by-side columns.
+type ColumnPrinter struct {
+ mu sync.Mutex
+ stdout io.Writer
+ columns int
+ colWidth int
+ partial []string
+ providers []string
+ models []string
+}
+
+type columnWriter struct {
+ printer *ColumnPrinter
+ index int
+}
+
+// NewColumnPrinter builds a multi-column printer for the provider/model pairs.
+func NewColumnPrinter(stdout io.Writer, providers []string, models []string) *ColumnPrinter {
+ cols := len(providers)
+ if len(models) > cols {
+ cols = len(models)
+ }
+ if cols == 0 {
+ return nil
+ }
+
+ width := detectTerminalWidth(stdout)
+ if width <= 0 {
+ width = 100
+ }
+ sepWidth := (cols - 1) * 3
+ colWidth := (width - sepWidth) / cols
+ if colWidth < 20 {
+ colWidth = 20
+ }
+
+ providerCols := make([]string, cols)
+ copy(providerCols, providers)
+ modelCols := make([]string, cols)
+ copy(modelCols, models)
+
+ return &ColumnPrinter{
+ stdout: stdout,
+ columns: cols,
+ colWidth: colWidth,
+ partial: make([]string, cols),
+ providers: providerCols,
+ models: modelCols,
+ }
+}
+
+func detectTerminalWidth(w io.Writer) int {
+ type fder interface{ Fd() uintptr }
+ if f, ok := w.(*os.File); ok {
+ if width, _, err := term.GetSize(int(f.Fd())); err == nil {
+ return width
+ }
+ }
+ if f, ok := w.(fder); ok {
+ if width, _, err := term.GetSize(int(f.Fd())); err == nil {
+ return width
+ }
+ }
+ return 0
+}
+
+// Writer returns an io.Writer that routes chunks to a single column index.
+func (cp *ColumnPrinter) Writer(idx int) io.Writer {
+ return columnWriter{printer: cp, index: idx}
+}
+
+// PrintHeader writes provider/model headers and a divider row.
+func (cp *ColumnPrinter) PrintHeader() {
+ cp.mu.Lock()
+ defer cp.mu.Unlock()
+ combo := make([]string, cp.columns)
+ for i := 0; i < cp.columns; i++ {
+ provider := strings.TrimSpace(cp.providers[i])
+ model := strings.TrimSpace(cp.models[i])
+ switch {
+ case provider != "" && model != "":
+ combo[i] = provider + ":" + model
+ case provider != "":
+ combo[i] = provider
+ case model != "":
+ combo[i] = model
+ default:
+ combo[i] = ""
+ }
+ }
+ cp.writeLine(combo)
+ divider := make([]string, cp.columns)
+ line := strings.Repeat("─", cp.colWidth)
+ for i := range divider {
+ divider[i] = line
+ }
+ cp.writeLine(divider)
+}
+
+// Flush emits any buffered partial line for a column.
+func (cp *ColumnPrinter) Flush(idx int) {
+ cp.mu.Lock()
+ defer cp.mu.Unlock()
+ if idx < 0 || idx >= len(cp.partial) {
+ return
+ }
+ if cp.partial[idx] == "" {
+ return
+ }
+ cp.emitJobLine(idx, cp.partial[idx])
+ cp.partial[idx] = ""
+}
+
+func (w columnWriter) Write(p []byte) (int, error) {
+ return w.printer.write(w.index, string(p))
+}
+
+func (cp *ColumnPrinter) write(idx int, data string) (int, error) {
+ cp.mu.Lock()
+ defer cp.mu.Unlock()
+ if idx < 0 || idx >= len(cp.partial) {
+ return len(data), nil
+ }
+ data = strings.ReplaceAll(data, "\r", "")
+ cp.partial[idx] += data
+ for strings.Contains(cp.partial[idx], "\n") {
+ line, rest, _ := strings.Cut(cp.partial[idx], "\n")
+ cp.partial[idx] = rest
+ cp.emitJobLine(idx, line)
+ }
+ return len(data), nil
+}
+
+func (cp *ColumnPrinter) emitJobLine(idx int, line string) {
+ segments := cp.wrap(line)
+ for _, seg := range segments {
+ cells := make([]string, cp.columns)
+ if idx >= 0 && idx < len(cells) {
+ cells[idx] = seg
+ }
+ cp.writeLine(cells)
+ }
+}
+
+func (cp *ColumnPrinter) wrap(text string) []string {
+ text = strings.ReplaceAll(text, "\t", " ")
+ if runewidth.StringWidth(text) <= cp.colWidth {
+ return []string{text}
+ }
+ var lines []string
+ var current strings.Builder
+ width := 0
+ for _, r := range text {
+ rw := runewidth.RuneWidth(r)
+ if width+rw > cp.colWidth && current.Len() > 0 {
+ lines = append(lines, current.String())
+ current.Reset()
+ width = 0
+ }
+ current.WriteRune(r)
+ width += rw
+ }
+ if current.Len() > 0 {
+ lines = append(lines, current.String())
+ }
+ if len(lines) == 0 {
+ lines = append(lines, "")
+ }
+ return lines
+}
+
+func (cp *ColumnPrinter) writeLine(cells []string) {
+ if len(cells) < cp.columns {
+ extra := make([]string, cp.columns-len(cells))
+ cells = append(cells, extra...)
+ }
+ var builder strings.Builder
+ for i := 0; i < cp.columns; i++ {
+ cell := cells[i]
+ width := runewidth.StringWidth(cell)
+ if width > cp.colWidth {
+ cell = runewidth.Truncate(cell, cp.colWidth, "…")
+ width = runewidth.StringWidth(cell)
+ }
+ builder.WriteString(cell)
+ if pad := cp.colWidth - width; pad > 0 {
+ builder.WriteString(strings.Repeat(" ", pad))
+ }
+ if i != cp.columns-1 {
+ builder.WriteString(" │ ")
+ }
+ }
+ builder.WriteByte('\n')
+ _, _ = cp.stdout.Write([]byte(builder.String()))
+}