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
1 change: 1 addition & 0 deletions cmd/root/io.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func (f *outputFlag) initializeIO(ctx context.Context, cmd *cobra.Command) (cont

cmdIO := cmdio.NewIO(ctx, f.output, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), headerTemplate, template)
ctx = cmdio.InContext(ctx, cmdIO)
ctx = cmdio.WithCommand(ctx, cmd)
cmd.SetContext(ctx)
return ctx, nil
}
44 changes: 1 addition & 43 deletions experimental/aitools/cmd/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,11 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
"text/tabwriter"

"github.com/databricks/cli/libs/tableview"
"github.com/databricks/databricks-sdk-go/service/sql"
)

const (
// maxColumnWidth is the maximum display width for any single column in static table output.
maxColumnWidth = 40
)

// extractColumns returns column names from the query result manifest.
func extractColumns(manifest *sql.ResultManifest) []string {
if manifest == nil || manifest.Schema == nil {
Expand Down Expand Up @@ -53,42 +46,7 @@ func renderJSON(w io.Writer, columns []string, rows [][]string) error {

// renderStaticTable writes query results as a formatted text table.
func renderStaticTable(w io.Writer, columns []string, rows [][]string) error {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)

// Header row.
fmt.Fprintln(tw, strings.Join(columns, "\t"))

// Separator.
seps := make([]string, len(columns))
for i, col := range columns {
width := len(col)
for _, row := range rows {
if i < len(row) {
width = max(width, len(row[i]))
}
}
width = min(width, maxColumnWidth)
seps[i] = strings.Repeat("-", width)
}
fmt.Fprintln(tw, strings.Join(seps, "\t"))

// Data rows.
for _, row := range rows {
vals := make([]string, len(columns))
for i := range columns {
if i < len(row) {
vals[i] = row[i]
}
}
fmt.Fprintln(tw, strings.Join(vals, "\t"))
}

if err := tw.Flush(); err != nil {
return err
}

fmt.Fprintf(w, "\n%d rows\n", len(rows))
return nil
return tableview.RenderStaticTable(w, columns, rows)
}

// renderInteractiveTable displays query results in the interactive table browser.
Expand Down
6 changes: 6 additions & 0 deletions libs/cmdio/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ func (c Capabilities) SupportsPrompt() bool {
return c.SupportsInteractive() && c.stdinIsTTY && !c.isGitBash
}

// SupportsTUI returns true when the terminal supports a full interactive TUI.
// Requires stdin (keyboard), stderr (prompts), and stdout (TUI output) all be TTYs with color.
func (c Capabilities) SupportsTUI() bool {
return c.stdinIsTTY && c.stdoutIsTTY && c.stderrIsTTY && c.color && !c.isGitBash
}

// SupportsColor returns true if the given writer supports colored output.
// This checks both TTY status and environment variables (NO_COLOR, TERM=dumb).
func (c Capabilities) SupportsColor(w io.Writer) bool {
Expand Down
33 changes: 33 additions & 0 deletions libs/cmdio/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package cmdio

import (
"context"

"github.com/spf13/cobra"
)

type cmdKeyType struct{}

// WithCommand stores the cobra.Command in context.
func WithCommand(ctx context.Context, cmd *cobra.Command) context.Context {
return context.WithValue(ctx, cmdKeyType{}, cmd)
}

// CommandFromContext retrieves the cobra.Command from context.
func CommandFromContext(ctx context.Context) *cobra.Command {
cmd, _ := ctx.Value(cmdKeyType{}).(*cobra.Command)
return cmd
}

type maxItemsKeyType struct{}

// WithMaxItems stores a max items limit in context.
func WithMaxItems(ctx context.Context, n int) context.Context {
return context.WithValue(ctx, maxItemsKeyType{}, n)
}

// GetMaxItems retrieves the max items limit from context (0 = unlimited).
func GetMaxItems(ctx context.Context) int {
n, _ := ctx.Value(maxItemsKeyType{}).(int)
return n
}
27 changes: 27 additions & 0 deletions libs/cmdio/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/flags"
"github.com/databricks/cli/libs/tableview"
"github.com/databricks/databricks-sdk-go/listing"
"github.com/fatih/color"
"github.com/nwidger/jsoncolor"
Expand Down Expand Up @@ -265,6 +266,32 @@ func Render(ctx context.Context, v any) error {

func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error {
c := fromContext(ctx)

// Only launch TUI when an explicit TableConfig is registered.
// AutoDetect is available but opt-in from the override layer.
if c.outputFormat == flags.OutputText && c.capabilities.SupportsTUI() {
cmd := CommandFromContext(ctx)
if cmd != nil {
if cfg := tableview.GetConfig(cmd); cfg != nil {
iter := tableview.WrapIterator(i, cfg.Columns)
maxItems := GetMaxItems(ctx)
p := tableview.NewPaginatedProgram(ctx, c.out, cfg, iter, maxItems)
c.acquireTeaProgram(p)
defer c.releaseTeaProgram()
finalModel, err := p.Run()
if err != nil {
return err
}
if pm, ok := finalModel.(tableview.FinalModel); ok {
if modelErr := pm.Err(); modelErr != nil {
return modelErr
}
}
return nil
}
}
}

return renderWithTemplate(ctx, newIteratorRenderer(i), c.outputFormat, c.out, c.headerTemplate, c.template)
}

Expand Down
116 changes: 116 additions & 0 deletions libs/tableview/autodetect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package tableview

import (
"fmt"
"reflect"
"strings"
"sync"
"unicode"

"github.com/databricks/databricks-sdk-go/listing"
)

const maxAutoColumns = 8

var autoCache sync.Map // reflect.Type -> *TableConfig

// AutoDetect creates a TableConfig by reflecting on the element type of the iterator.
// It picks up to maxAutoColumns top-level scalar fields.
// Returns nil if no suitable columns are found.
func AutoDetect[T any](iter listing.Iterator[T]) *TableConfig {
var zero T
t := reflect.TypeOf(zero)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}

if cached, ok := autoCache.Load(t); ok {
return cached.(*TableConfig)
}

cfg := autoDetectFromType(t)
if cfg != nil {
autoCache.Store(t, cfg)
}
return cfg
}

func autoDetectFromType(t reflect.Type) *TableConfig {
if t.Kind() != reflect.Struct {
return nil
}

var columns []ColumnDef
for i := range t.NumField() {
if len(columns) >= maxAutoColumns {
break
}
field := t.Field(i)
if !field.IsExported() || field.Anonymous {
continue
}
if !isScalarKind(field.Type.Kind()) {
continue
}

header := fieldHeader(field)
columns = append(columns, ColumnDef{
Header: header,
Extract: func(v any) string {
val := reflect.ValueOf(v)
if val.Kind() == reflect.Ptr {
if val.IsNil() {
return ""
}
val = val.Elem()
}
f := val.Field(i)
return fmt.Sprintf("%v", f.Interface())
},
})
}

if len(columns) == 0 {
return nil
}
return &TableConfig{Columns: columns}
}

func isScalarKind(k reflect.Kind) bool {
switch k {
case reflect.String, reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return true
default:
return false
}
}

// fieldHeader converts a struct field to a display header.
// Uses the json tag if available, otherwise the field name.
func fieldHeader(f reflect.StructField) string {
tag := f.Tag.Get("json")
if tag != "" {
name, _, _ := strings.Cut(tag, ",")
if name != "" && name != "-" {
return snakeToTitle(name)
}
}
return f.Name
}

func snakeToTitle(s string) string {
words := strings.Split(s, "_")
for i, w := range words {
if w == "id" {
words[i] = "ID"
} else if len(w) > 0 {
runes := []rune(w)
runes[0] = unicode.ToUpper(runes[0])
words[i] = string(runes)
}
}
return strings.Join(words, " ")
}
Loading
Loading