diff options
| -rw-r--r-- | internal/hexaicli/run.go | 191 | ||||
| -rw-r--r-- | internal/termprint/columns.go | 207 |
2 files changed, 215 insertions, 183 deletions
diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go index 1505f31..06ae08a 100644 --- a/internal/hexaicli/run.go +++ b/internal/hexaicli/run.go @@ -19,9 +19,8 @@ import ( "codeberg.org/snonux/hexai/internal/llmutils" "codeberg.org/snonux/hexai/internal/logging" "codeberg.org/snonux/hexai/internal/stats" + "codeberg.org/snonux/hexai/internal/termprint" "codeberg.org/snonux/hexai/internal/tmux" - "github.com/mattn/go-runewidth" - "golang.org/x/term" ) type requestArgs struct { @@ -37,21 +36,6 @@ type cliJob struct { req requestArgs } -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 -} - type ( selectionContextKey struct{} configPathContextKey struct{} @@ -218,7 +202,7 @@ func runCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input st return writeCLIJobSummaries(stderr, results) } -func executeCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input string, stdout io.Writer, stderr io.Writer) ([]*cliJobResult, *columnPrinter) { +func executeCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input string, stdout io.Writer, stderr io.Writer) ([]*cliJobResult, *termprint.ColumnPrinter) { results := make([]*cliJobResult, len(jobs)) printer := setupCLIPrinter(stdout, jobs) var wg sync.WaitGroup @@ -235,7 +219,7 @@ func executeCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, inpu return results, printer } -func setupCLIPrinter(stdout io.Writer, jobs []cliJob) *columnPrinter { +func setupCLIPrinter(stdout io.Writer, jobs []cliJob) *termprint.ColumnPrinter { if len(jobs) == 0 { return nil } @@ -244,7 +228,7 @@ func setupCLIPrinter(stdout io.Writer, jobs []cliJob) *columnPrinter { return printer } -func runSingleCLIJob(ctx context.Context, job cliJob, msgs []llm.Message, input string, printer *columnPrinter) *cliJobResult { +func runSingleCLIJob(ctx context.Context, job cliJob, msgs []llm.Message, input string, printer *termprint.ColumnPrinter) *cliJobResult { var errBuf bytes.Buffer var outBuf bytes.Buffer jobMsgs := append([]llm.Message(nil), msgs...) @@ -332,173 +316,14 @@ func writeCLIJobSummary(stderr io.Writer, res *cliJobResult) error { return err } -func newColumnPrinter(stdout io.Writer, jobs []cliJob) *columnPrinter { - cols := len(jobs) - width := detectTerminalWidth(stdout) - if width <= 0 { - width = 100 - } - sepWidth := (cols - 1) * 3 - colWidth := (width - sepWidth) / cols - if colWidth < 20 { - colWidth = 20 - } - providers := make([]string, cols) - models := make([]string, cols) +func newColumnPrinter(stdout io.Writer, jobs []cliJob) *termprint.ColumnPrinter { + providers := make([]string, len(jobs)) + models := make([]string, len(jobs)) for _, job := range jobs { providers[job.index] = job.client.Name() models[job.index] = job.req.model } - return &columnPrinter{ - stdout: stdout, - columns: cols, - colWidth: colWidth, - partial: make([]string, cols), - providers: providers, - models: models, - } -} - -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 -} - -func (cp *columnPrinter) Writer(idx int) io.Writer { - return columnWriter{printer: cp, index: idx} -} - -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) -} - -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())) + return termprint.NewColumnPrinter(stdout, providers, models) } // WithCLISelection injects provider indices into the context so Run only executes those jobs. 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())) +} |
