Skip to content

Commit ef8c413

Browse files
committed
Add endless-scrolling TUI table for interactive list commands
Co-authored-by: Isaac
1 parent 309f527 commit ef8c413

File tree

15 files changed

+1531
-161
lines changed

15 files changed

+1531
-161
lines changed

cmd/root/io.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func (f *outputFlag) initializeIO(ctx context.Context, cmd *cobra.Command) (cont
4949

5050
cmdIO := cmdio.NewIO(ctx, f.output, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), headerTemplate, template)
5151
ctx = cmdio.InContext(ctx, cmdIO)
52+
ctx = cmdio.WithCommand(ctx, cmd)
5253
cmd.SetContext(ctx)
5354
return ctx, nil
5455
}

experimental/aitools/cmd/render.go

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,11 @@ import (
44
"encoding/json"
55
"fmt"
66
"io"
7-
"strings"
8-
"text/tabwriter"
97

108
"github.com/databricks/cli/libs/tableview"
119
"github.com/databricks/databricks-sdk-go/service/sql"
1210
)
1311

14-
const (
15-
// maxColumnWidth is the maximum display width for any single column in static table output.
16-
maxColumnWidth = 40
17-
)
18-
1912
// extractColumns returns column names from the query result manifest.
2013
func extractColumns(manifest *sql.ResultManifest) []string {
2114
if manifest == nil || manifest.Schema == nil {
@@ -53,42 +46,7 @@ func renderJSON(w io.Writer, columns []string, rows [][]string) error {
5346

5447
// renderStaticTable writes query results as a formatted text table.
5548
func renderStaticTable(w io.Writer, columns []string, rows [][]string) error {
56-
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
57-
58-
// Header row.
59-
fmt.Fprintln(tw, strings.Join(columns, "\t"))
60-
61-
// Separator.
62-
seps := make([]string, len(columns))
63-
for i, col := range columns {
64-
width := len(col)
65-
for _, row := range rows {
66-
if i < len(row) {
67-
width = max(width, len(row[i]))
68-
}
69-
}
70-
width = min(width, maxColumnWidth)
71-
seps[i] = strings.Repeat("-", width)
72-
}
73-
fmt.Fprintln(tw, strings.Join(seps, "\t"))
74-
75-
// Data rows.
76-
for _, row := range rows {
77-
vals := make([]string, len(columns))
78-
for i := range columns {
79-
if i < len(row) {
80-
vals[i] = row[i]
81-
}
82-
}
83-
fmt.Fprintln(tw, strings.Join(vals, "\t"))
84-
}
85-
86-
if err := tw.Flush(); err != nil {
87-
return err
88-
}
89-
90-
fmt.Fprintf(w, "\n%d rows\n", len(rows))
91-
return nil
49+
return tableview.RenderStaticTable(w, columns, rows)
9250
}
9351

9452
// renderInteractiveTable displays query results in the interactive table browser.

libs/cmdio/capabilities.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ func (c Capabilities) SupportsPrompt() bool {
4242
return c.SupportsInteractive() && c.stdinIsTTY && !c.isGitBash
4343
}
4444

45+
// SupportsTUI returns true when the terminal supports a full interactive TUI.
46+
// Requires stdin (keyboard), stderr (prompts), and stdout (TUI output) all be TTYs with color.
47+
func (c Capabilities) SupportsTUI() bool {
48+
return c.stdinIsTTY && c.stdoutIsTTY && c.stderrIsTTY && c.color && !c.isGitBash
49+
}
50+
4551
// SupportsColor returns true if the given writer supports colored output.
4652
// This checks both TTY status and environment variables (NO_COLOR, TERM=dumb).
4753
func (c Capabilities) SupportsColor(w io.Writer) bool {

libs/cmdio/context.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package cmdio
2+
3+
import (
4+
"context"
5+
6+
"github.com/spf13/cobra"
7+
)
8+
9+
type cmdKeyType struct{}
10+
11+
// WithCommand stores the cobra.Command in context.
12+
func WithCommand(ctx context.Context, cmd *cobra.Command) context.Context {
13+
return context.WithValue(ctx, cmdKeyType{}, cmd)
14+
}
15+
16+
// CommandFromContext retrieves the cobra.Command from context.
17+
func CommandFromContext(ctx context.Context) *cobra.Command {
18+
cmd, _ := ctx.Value(cmdKeyType{}).(*cobra.Command)
19+
return cmd
20+
}
21+
22+
type maxItemsKeyType struct{}
23+
24+
// WithMaxItems stores a max items limit in context.
25+
func WithMaxItems(ctx context.Context, n int) context.Context {
26+
return context.WithValue(ctx, maxItemsKeyType{}, n)
27+
}
28+
29+
// GetMaxItems retrieves the max items limit from context (0 = unlimited).
30+
func GetMaxItems(ctx context.Context) int {
31+
n, _ := ctx.Value(maxItemsKeyType{}).(int)
32+
return n
33+
}

libs/cmdio/render.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/databricks/cli/libs/diag"
1717
"github.com/databricks/cli/libs/flags"
18+
"github.com/databricks/cli/libs/tableview"
1819
"github.com/databricks/databricks-sdk-go/listing"
1920
"github.com/fatih/color"
2021
"github.com/nwidger/jsoncolor"
@@ -265,6 +266,28 @@ func Render(ctx context.Context, v any) error {
265266

266267
func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error {
267268
c := fromContext(ctx)
269+
270+
// Launch paginated TUI when interactive and text output.
271+
if c.outputFormat == flags.OutputText && c.capabilities.SupportsTUI() {
272+
cmd := CommandFromContext(ctx)
273+
var cfg *tableview.TableConfig
274+
if cmd != nil {
275+
cfg = tableview.GetConfig(cmd)
276+
}
277+
if cfg == nil {
278+
cfg = tableview.AutoDetect(i)
279+
}
280+
if cfg != nil {
281+
iter := tableview.WrapIterator(i, cfg.Columns)
282+
maxItems := GetMaxItems(ctx)
283+
p := tableview.NewPaginatedProgram(ctx, c.out, cfg, iter, maxItems)
284+
c.acquireTeaProgram(p)
285+
defer c.releaseTeaProgram()
286+
_, err := p.Run()
287+
return err
288+
}
289+
}
290+
268291
return renderWithTemplate(ctx, newIteratorRenderer(i), c.outputFormat, c.out, c.headerTemplate, c.template)
269292
}
270293

libs/tableview/autodetect.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package tableview
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"strings"
7+
"sync"
8+
"unicode"
9+
10+
"github.com/databricks/databricks-sdk-go/listing"
11+
)
12+
13+
const maxAutoColumns = 8
14+
15+
var autoCache sync.Map // reflect.Type -> *TableConfig
16+
17+
// AutoDetect creates a TableConfig by reflecting on the element type of the iterator.
18+
// It picks up to maxAutoColumns top-level scalar fields.
19+
// Returns nil if no suitable columns are found.
20+
func AutoDetect[T any](iter listing.Iterator[T]) *TableConfig {
21+
var zero T
22+
t := reflect.TypeOf(zero)
23+
if t.Kind() == reflect.Ptr {
24+
t = t.Elem()
25+
}
26+
27+
if cached, ok := autoCache.Load(t); ok {
28+
return cached.(*TableConfig)
29+
}
30+
31+
cfg := autoDetectFromType(t)
32+
if cfg != nil {
33+
autoCache.Store(t, cfg)
34+
}
35+
return cfg
36+
}
37+
38+
func autoDetectFromType(t reflect.Type) *TableConfig {
39+
if t.Kind() != reflect.Struct {
40+
return nil
41+
}
42+
43+
var columns []ColumnDef
44+
for i := range t.NumField() {
45+
if len(columns) >= maxAutoColumns {
46+
break
47+
}
48+
field := t.Field(i)
49+
if !field.IsExported() || field.Anonymous {
50+
continue
51+
}
52+
if !isScalarKind(field.Type.Kind()) {
53+
continue
54+
}
55+
56+
header := fieldHeader(field)
57+
columns = append(columns, ColumnDef{
58+
Header: header,
59+
Extract: func(v any) string {
60+
val := reflect.ValueOf(v)
61+
if val.Kind() == reflect.Ptr {
62+
if val.IsNil() {
63+
return ""
64+
}
65+
val = val.Elem()
66+
}
67+
f := val.Field(i)
68+
return fmt.Sprintf("%v", f.Interface())
69+
},
70+
})
71+
}
72+
73+
if len(columns) == 0 {
74+
return nil
75+
}
76+
return &TableConfig{Columns: columns}
77+
}
78+
79+
func isScalarKind(k reflect.Kind) bool {
80+
switch k {
81+
case reflect.String, reflect.Bool,
82+
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
83+
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
84+
reflect.Float32, reflect.Float64:
85+
return true
86+
default:
87+
return false
88+
}
89+
}
90+
91+
// fieldHeader converts a struct field to a display header.
92+
// Uses the json tag if available, otherwise the field name.
93+
func fieldHeader(f reflect.StructField) string {
94+
tag := f.Tag.Get("json")
95+
if tag != "" {
96+
name, _, _ := strings.Cut(tag, ",")
97+
if name != "" && name != "-" {
98+
return snakeToTitle(name)
99+
}
100+
}
101+
return f.Name
102+
}
103+
104+
func snakeToTitle(s string) string {
105+
words := strings.Split(s, "_")
106+
for i, w := range words {
107+
if w == "id" {
108+
words[i] = "ID"
109+
} else if len(w) > 0 {
110+
runes := []rune(w)
111+
runes[0] = unicode.ToUpper(runes[0])
112+
words[i] = string(runes)
113+
}
114+
}
115+
return strings.Join(words, " ")
116+
}

0 commit comments

Comments
 (0)