summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/hexaicli/run.go191
-rw-r--r--internal/termprint/columns.go207
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()))
+}