Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e672906
Add endless-scrolling TUI table for interactive list commands
simonfaltum Mar 12, 2026
d7a7104
Fix stale fetch race condition and empty-table search restore
simonfaltum Mar 13, 2026
8b589d1
Merge branch 'main' into simonfaltum/list-tui-paginated
simonfaltum Mar 13, 2026
b7ca3d8
Add debounced live search to paginated TUI table
simonfaltum Mar 13, 2026
aa4cdb0
Fix search/fetch race conditions, esc restore, and error propagation
simonfaltum Mar 13, 2026
dbd2241
Fix lint: use assert.NoError per testifylint rule
simonfaltum Mar 13, 2026
831b105
Merge branch 'main' into simonfaltum/list-tui-paginated
simonfaltum Mar 14, 2026
229baa9
Fix UTF-8 backspace corruption and fragile typing check in search
simonfaltum Mar 16, 2026
78e6720
Fix RunPaginated silently dropping model-level fetch errors
simonfaltum Mar 16, 2026
78b56b7
Recompute column widths on every batch, not just the first
simonfaltum Mar 16, 2026
96e614a
Extract restorePreSearchState to fix DRY violation and stuck loading
simonfaltum Mar 16, 2026
3143a72
Show fetch errors in footer instead of replacing loaded data
simonfaltum Mar 16, 2026
a548b79
Fix sticky errors, missing space input, and search/fetch race in TUI …
simonfaltum Mar 16, 2026
43c526d
Fix search cancel silently dropping in-flight fetch rows
simonfaltum Mar 16, 2026
4281953
Fix search/fetch race: preserve loading state when no search was active
simonfaltum Mar 16, 2026
b3a6a53
Fix loading state stuck after canceling search without executing
simonfaltum Mar 16, 2026
7b4c0ee
Separate search and loading concerns in paginated model
simonfaltum Mar 16, 2026
aaf150e
Fix lint: remove extra blank line, convert if/else to switch
simonfaltum Mar 16, 2026
5709b10
Fix exhaustive lint: replace switch on tea.KeyType with if-else
simonfaltum Mar 17, 2026
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