diff --git a/.nextchanges/air/7629.md b/.nextchanges/air/7629.md
new file mode 100644
index 00000000000..c420a218459
--- /dev/null
+++ b/.nextchanges/air/7629.md
@@ -0,0 +1 @@
+* Fit the interactive `air list` table to narrow terminals: when the Experiment and User columns are at their floors, shorten Started to `MM-DD HH:MM` and size the MLflow column to its labels, then hide Accelerators, User, MLflow and Progress in that order, naming the hidden columns in the hint line. ([#7629](https://github.com/databricks/cli/pull/7629))
diff --git a/cmd/air/list_tui.go b/cmd/air/list_tui.go
index 53ae3b3980f..505a329bdeb 100644
--- a/cmd/air/list_tui.go
+++ b/cmd/air/list_tui.go
@@ -190,7 +190,7 @@ func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.height = msg.Height
m.width = msg.Width
- m.cols = computeListCols(m.rows).fitWidth(m.width)
+ m.cols = layoutListCols(m.rows, m.width)
m.viewport.Width = msg.Width
m.viewport.Height = max(msg.Height-3, 1)
m.viewport.SetContent(m.wrappedDetail())
@@ -204,7 +204,9 @@ func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.rows = append(m.rows, msg.rows...)
- m.cols = computeListCols(m.rows).fitWidth(m.width)
+ // A new page can only widen the natural columns, so the layout moves one
+ // way as pages load: a column may compact or disappear, never reappear.
+ m.cols = layoutListCols(m.rows, m.width)
m.offset = m.clampedOffset()
// A page with no matches but more to scan: keep paging so the cursor isn't
// stuck at the end of the loaded rows.
@@ -361,18 +363,77 @@ func (m listModel) View() string {
return strings.Join(lines, "\n") + "\n"
}
-// renderHint is the faint one-line key legend, with the cursor position and the
-// paging state (loading / load failed).
+// Key legends for the hint line: the full one, and the keys alone for when the
+// full one leaves no room for the rest of the hint.
+const (
+ hintLegend = "↑/↓ navigate · ←/→ page · ↵ mlflow · i info · L logs · q quit"
+ hintLegendShort = "↑/↓ ←/→ ↵ i L q"
+)
+
+// renderHint is the faint one-line hint: the key legend, the cursor position,
+// the paging state (loading / load failed) and, in a narrow terminal, the
+// columns the layout has hidden. It must fit the terminal, or bubbletea cuts
+// off its end, so it gives up detail in steps: the legend shrinks to its keys,
+// then the hidden columns are counted instead of named, then the legend goes,
+// and as a last resort the line is truncated.
func (m listModel) renderHint() string {
faint := m.styles.r.NewStyle().Foreground(colN7)
- hint := fmt.Sprintf("↑/↓ navigate · ←/→ page · ↵ mlflow · i info · L logs · q quit · row %d/%d", m.cursor+1, len(m.rows))
+ hidden := m.cols.hiddenNames()
+ variants := []struct {
+ legend string
+ note string
+ }{
+ {hintLegend, hiddenNote(hidden, true)},
+ {hintLegendShort, hiddenNote(hidden, true)},
+ {hintLegendShort, hiddenNote(hidden, false)},
+ {"", hiddenNote(hidden, false)},
+ }
+ for _, v := range variants {
+ if m.width <= 0 || lipgloss.Width(m.hintText(v.legend, v.note, true)) <= m.width {
+ return faint.Render(m.hintText(v.legend, v.note, false))
+ }
+ }
+ last := variants[len(variants)-1]
+ return faint.Render(truncate(m.hintText(last.legend, last.note, false), m.width))
+}
+
+// hintText assembles the hint from its (possibly empty) legend and
+// hidden-columns note. With widest set, the row counter and paging state take
+// the widest form they have while pages load ("row N/N (loading…)"), so the
+// choice of variant doesn't change as the cursor moves or a page loads. A load
+// failure is permanent, so it is measured as is.
+func (m listModel) hintText(legend, note string, widest bool) string {
+ row, state := m.cursor+1, ""
switch {
case m.loadErr != nil:
- hint += " (load failed)"
- case m.loading:
- hint += " (loading…)"
+ state = " (load failed)"
+ case widest, m.loading:
+ state = " (loading…)"
+ }
+ if widest {
+ row = len(m.rows)
+ }
+ hint := fmt.Sprintf("row %d/%d%s%s", row, len(m.rows), state, note)
+ if legend == "" {
+ return hint
+ }
+ return legend + " · " + hint
+}
+
+// hiddenNote is the hint's hidden-columns note: "" when every column is shown,
+// otherwise the hidden columns by name, or just their count when names is
+// false.
+func hiddenNote(hidden []string, names bool) string {
+ switch {
+ case len(hidden) == 0:
+ return ""
+ case names:
+ return " · hidden: " + strings.Join(hidden, ", ")
+ case len(hidden) == 1:
+ return " · 1 column hidden"
+ default:
+ return fmt.Sprintf(" · %d columns hidden", len(hidden))
}
- return faint.Render(hint)
}
// fetchRunDetail returns a tea.Cmd that fetches run details in the background.
diff --git a/cmd/air/list_tui_render.go b/cmd/air/list_tui_render.go
index 2152663430d..7f70f5ce779 100644
--- a/cmd/air/list_tui_render.go
+++ b/cmd/air/list_tui_render.go
@@ -23,6 +23,8 @@ const (
colBlue = lipgloss.Color("#6CA8F0") // MLflow link
)
+// mlflowColWidth is the MLflow column's width in a wide terminal, and the width
+// MLflow labels are cut to when the rows are built.
const mlflowColWidth = 22
// listStyles renders the runs table. The renderer carries the color profile, so
@@ -35,16 +37,45 @@ func newListStyles(r *lipgloss.Renderer) listStyles {
return listStyles{r: r}
}
-// listCols holds the computed width of each variable-width column. MLflow is
-// fixed (a short link) and the gutter is one cell.
+// listColumn identifies a table column, in display order.
+type listColumn int
+
+const (
+ columnRunID listColumn = iota
+ columnExperiment
+ columnStatus
+ columnStarted
+ columnDuration
+ columnProgress
+ columnMLflow
+ columnUser
+ columnAccel
+)
+
+const numListColumns = int(columnAccel) + 1
+
+var listColumnNames = [numListColumns]string{
+ "Run ID", "Experiment", "Status", "Started", "Duration", "Progress", "MLflow", "User", "Accelerators",
+}
+
+// hideOrder lists the columns a too-narrow terminal drops, lowest priority
+// first.
+var hideOrder = []listColumn{columnAccel, columnUser, columnMLflow, columnProgress}
+
+// listCols holds the computed width of each column; the gutter is one cell.
+// compactStarted switches Started to its short form; hidden columns are left
+// out of the rendered rows.
type listCols struct {
- runID, experiment, status, started, duration, progress, user, accel int
+ runID, experiment, status, started, duration, progress, mlflow, user, accel int
+ compactStarted bool
+ hidden uint16 // bit i set when listColumn i is hidden
}
// columnCap bounds the widest free-text columns so one long value can't dominate
// the row.
const columnCap = 36
+// computeListCols sizes every column to its content.
func computeListCols(rows []listRow) listCols {
c := listCols{
runID: len("Run ID"),
@@ -53,6 +84,7 @@ func computeListCols(rows []listRow) listCols {
started: len("Started"),
duration: len("Duration"),
progress: len("Progress"),
+ mlflow: mlflowColWidth,
user: len("User"),
accel: len("Accelerators"),
}
@@ -76,19 +108,68 @@ const (
userMinWidth = 8
)
-// tableWidth is the full width of a rendered row: every column plus the gutter
-// and the two-cell separators.
+func (c listCols) width(col listColumn) int {
+ switch col {
+ case columnRunID:
+ return c.runID
+ case columnExperiment:
+ return c.experiment
+ case columnStatus:
+ return c.status
+ case columnStarted:
+ return c.started
+ case columnDuration:
+ return c.duration
+ case columnProgress:
+ return c.progress
+ case columnMLflow:
+ return c.mlflow
+ case columnUser:
+ return c.user
+ case columnAccel:
+ return c.accel
+ }
+ return 0
+}
+
+func (c listCols) shown(col listColumn) bool {
+ return c.hidden&(1<
0; overflow-- {
canShrinkExperiment := c.experiment > experimentFloor
- canShrinkUser := c.user > userFloor
+ canShrinkUser := c.shown(columnUser) && c.user > userFloor
switch {
case canShrinkExperiment && (!canShrinkUser || c.experiment >= c.user):
c.experiment--
@@ -110,22 +191,60 @@ func (c listCols) fitWidth(width int) listCols {
return c
}
+// compact switches the fixed columns to their short forms: Started as
+// "MM-DD HH:MM" and MLflow as wide as its widest label rather than the full
+// mlflowColWidth. Labels were already cut to that width when the rows were
+// built, so they are never shortened again here.
+func (c listCols) compact(rows []listRow) listCols {
+ c.compactStarted = true
+ c.started = len("Started")
+ c.mlflow = len("MLflow")
+ for _, r := range rows {
+ c.started = max(c.started, lipgloss.Width(startedCompact(r)))
+ c.mlflow = max(c.mlflow, lipgloss.Width(mlflowLabel(r)))
+ }
+ c.mlflow = min(c.mlflow, mlflowColWidth)
+ return c
+}
+
+// layoutListCols lays the interactive table out for a terminal width cells
+// wide. Experiment and User shrink to their floors first. If the table is still
+// too wide, the fixed columns compact, and then whole columns are hidden in
+// hideOrder until it fits or none are left to hide; after each of those steps
+// Experiment and User are refitted, so they widen into any spare cells. A
+// terminal wide enough for the uncompacted table gets exactly that, and a
+// width of 0 (unknown) leaves the natural widths alone.
+func layoutListCols(rows []listRow, width int) listCols {
+ natural := computeListCols(rows)
+ if width <= 0 {
+ return natural
+ }
+ cols := natural.fitWidth(width)
+ if cols.tableWidth() <= width {
+ return cols
+ }
+ compact := natural.compact(rows)
+ cols = compact.fitWidth(width)
+ for _, col := range hideOrder {
+ if cols.tableWidth() <= width {
+ break
+ }
+ compact.hidden |= 1 << col
+ cols = compact.fitWidth(width)
+ }
+ return cols
+}
+
// renderHeader renders the muted column-title row.
func (s listStyles) renderHeader(cols listCols) string {
h := func(text string, width int, right bool) string {
return s.r.NewStyle().Foreground(colN9).Render(pad(text, width, right))
}
- cells := []string{
- pad(" ", 1, false),
- h("Run ID", cols.runID, false),
- h("Experiment", cols.experiment, false),
- h("Status", cols.status, false),
- h("Started", cols.started, false),
- h("Duration", cols.duration, true),
- h("Progress", cols.progress, true),
- h("MLflow", mlflowColWidth, false),
- h("User", cols.user, false),
- h("Accelerators", cols.accel, false),
+ cells := []string{pad(" ", 1, false)}
+ for col := range listColumn(numListColumns) {
+ if cols.shown(col) {
+ cells = append(cells, h(listColumnNames[col], cols.width(col), rightAligned(col)))
+ }
}
// Trim trailing pad on the final column so rows carry no trailing whitespace.
return strings.TrimRight(strings.Join(cells, " "), " ")
@@ -161,17 +280,34 @@ func (s listStyles) renderRow(cols listCols, r listRow, selected, links bool) st
// Underline only cells that actually carry a link, so unlinked text isn't
// styled as clickable.
- cells := []string{
- s.cell(base, gutter, 1, fg(colN7), false, false, ""),
- s.cell(base, r.RunID, cols.runID, fg(colRunID), false, runIDLink != "", runIDLink),
- s.cell(base, r.Experiment, cols.experiment, fg(colN11), false, experimentLink != "", experimentLink),
- s.cell(base, "● "+listRowStatus(r), cols.status, fg(statusColor(listRowStatus(r))), false, false, ""),
- s.cell(base, startedDisplay(r), cols.started, fg(colN9), false, false, ""),
- s.cell(base, r.Duration, cols.duration, fg(colN9), true, false, ""),
- s.cell(base, progressDisplay(r), cols.progress, fg(colAmber), true, false, ""),
- s.mlflowCell(base, r, selected, links),
- s.cell(base, r.User, cols.user, fg(colN9), false, false, ""),
- s.cell(base, r.Accelerators, cols.accel, fg(colN9), false, false, ""),
+ cell := func(col listColumn) string {
+ switch col {
+ case columnRunID:
+ return s.cell(base, r.RunID, cols.runID, fg(colRunID), false, runIDLink != "", runIDLink)
+ case columnExperiment:
+ return s.cell(base, r.Experiment, cols.experiment, fg(colN11), false, experimentLink != "", experimentLink)
+ case columnStatus:
+ return s.cell(base, "● "+listRowStatus(r), cols.status, fg(statusColor(listRowStatus(r))), false, false, "")
+ case columnStarted:
+ return s.cell(base, cols.startedText(r), cols.started, fg(colN9), false, false, "")
+ case columnDuration:
+ return s.cell(base, r.Duration, cols.duration, fg(colN9), rightAligned(col), false, "")
+ case columnProgress:
+ return s.cell(base, progressDisplay(r), cols.progress, fg(colAmber), rightAligned(col), false, "")
+ case columnMLflow:
+ return s.mlflowCell(base, cols.mlflow, r, selected, links)
+ case columnUser:
+ return s.cell(base, r.User, cols.user, fg(colN9), false, false, "")
+ case columnAccel:
+ return s.cell(base, r.Accelerators, cols.accel, fg(colN9), false, false, "")
+ }
+ return ""
+ }
+ cells := []string{s.cell(base, gutter, 1, fg(colN7), false, false, "")}
+ for col := range listColumn(numListColumns) {
+ if cols.shown(col) {
+ cells = append(cells, cell(col))
+ }
}
// Trim the final column's trailing pad so rows carry no trailing whitespace.
return strings.TrimRight(strings.Join(cells, base.Render(" ")), " ")
@@ -205,29 +341,31 @@ func (s listStyles) cell(base lipgloss.Style, text string, width int, fg lipglos
return rendered + padStr
}
-// mlflowCell renders the fixed-width MLflow column: a short, blue, underlined
-// OSC 8 hyperlink (when links are enabled), or "-" when the run has no link.
-func (s listStyles) mlflowCell(base lipgloss.Style, r listRow, selected, links bool) string {
- if r.MLflowURL == "" || r.MLflowURL == "-" {
- fg := colN9
- if selected {
- fg = colN12
- }
- return s.cell(base, "-", mlflowColWidth, fg, false, false, "")
- }
+// mlflowCell renders the MLflow column: a short, blue, underlined OSC 8
+// hyperlink (when links are enabled), or "-" when the run has no link.
+func (s listStyles) mlflowCell(base lipgloss.Style, width int, r listRow, selected, links bool) string {
+ label := mlflowLabel(r)
fg := colBlue
+ if label == "-" {
+ fg = colN9
+ }
if selected {
fg = colN12
}
link := ""
- if links {
+ if links && label != "-" {
link = r.MLflowURL
}
- label := r.MLflowLabel
- if label == "" || label == "-" {
- label = "-"
+ return s.cell(base, label, width, fg, false, label != "-", link)
+}
+
+// mlflowLabel is the text shown in the row's MLflow cell: its label, or "-"
+// when the run has no link or no label.
+func mlflowLabel(r listRow) string {
+ if r.MLflowURL == "" || r.MLflowURL == "-" || r.MLflowLabel == "" || r.MLflowLabel == "-" {
+ return "-"
}
- return s.cell(base, label, mlflowColWidth, fg, false, true, link)
+ return r.MLflowLabel
}
// statusColor maps an air run status word to its data color.
@@ -266,6 +404,27 @@ func startedDisplay(r listRow) string {
return s
}
+// startedCompact is the short form of the row's ISO start timestamp for a
+// narrow terminal ("01-02 15:04"), or "-" when the run hasn't started. A value
+// that isn't an ISO timestamp is shown as is.
+func startedCompact(r listRow) string {
+ if r.StartedAt == nil {
+ return "-"
+ }
+ s := *r.StartedAt
+ if len(s) >= 16 && s[4] == '-' && s[7] == '-' && s[10] == 'T' {
+ return s[5:10] + " " + s[11:16]
+ }
+ return s
+}
+
+func (c listCols) startedText(r listRow) string {
+ if c.compactStarted {
+ return startedCompact(r)
+ }
+ return startedDisplay(r)
+}
+
// pad pads (or truncates) s to a visible width of n, right-aligned when right is
// set. It measures visible width, so it is safe on styled strings.
func pad(s string, n int, right bool) string {
diff --git a/cmd/air/list_tui_test.go b/cmd/air/list_tui_test.go
index 7117aabaec6..b7c0cab1f7d 100644
--- a/cmd/air/list_tui_test.go
+++ b/cmd/air/list_tui_test.go
@@ -1,12 +1,15 @@
package aircmd
import (
+ "errors"
+ "fmt"
"io"
"strconv"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/env"
@@ -472,3 +475,318 @@ func TestStaticListTableIgnoresTerminalWidth(t *testing.T) {
// The one-shot table still cuts a long Experiment at its end.
assert.Contains(t, lines[1], " /Users/someone.longname@example.com… ")
}
+
+func TestLayoutListCols(t *testing.T) {
+ rows := wideListRows()
+ natural := computeListCols(rows)
+ require.Equal(t, 134, natural.fitWidth(134).tableWidth())
+
+ tests := []struct {
+ name string
+ width int
+ wantExperiment int
+ wantUser int
+ wantCompact bool
+ wantHidden []string
+ wantOverflow bool
+ }{
+ {name: "unknown width keeps natural columns", width: 0, wantExperiment: columnCap, wantUser: columnCap},
+ // Even at the floors the full table is 134 cells, so the compact tier
+ // applies and its 17 spare cells go back to Experiment and User.
+ {name: "133 compacts Started and MLflow", width: 133, wantExperiment: 18, wantUser: 19, wantCompact: true},
+ {name: "120 compacts and shows every column", width: 120, wantExperiment: 12, wantUser: 12, wantCompact: true},
+ // Hiding Accelerators alone leaves 102 cells, so User goes too and
+ // Experiment widens into the freed space.
+ {name: "100 hides Accelerators and User", width: 100, wantExperiment: 20, wantUser: 0, wantCompact: true, wantHidden: []string{"User", "Accelerators"}},
+ {name: "80 hides MLflow as well", width: 80, wantExperiment: 14, wantUser: 0, wantCompact: true, wantHidden: []string{"MLflow", "User", "Accelerators"}},
+ // With every hideable column gone the table is 66 cells, so it still
+ // overflows and the floors hold.
+ {name: "60 hides Progress and still overflows", width: 60, wantExperiment: experimentMinWidth, wantUser: 0, wantCompact: true, wantHidden: []string{"Progress", "MLflow", "User", "Accelerators"}, wantOverflow: true},
+ {name: "160 is unchanged from today", width: 160, wantExperiment: 23, wantUser: 23},
+ {name: "200 is unchanged from today", width: 200, wantExperiment: columnCap, wantUser: columnCap},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := layoutListCols(rows, tt.width)
+ assert.Equal(t, tt.wantExperiment, got.experiment)
+ if got.shown(columnUser) {
+ assert.Equal(t, tt.wantUser, got.user)
+ } else {
+ assert.Equal(t, 0, tt.wantUser, "User hidden but a width was expected")
+ }
+ assert.Equal(t, tt.wantCompact, got.compactStarted)
+ assert.Equal(t, tt.wantHidden, got.hiddenNames())
+ switch {
+ case tt.wantOverflow:
+ assert.Greater(t, got.tableWidth(), tt.width)
+ case tt.width > 0:
+ assert.LessOrEqual(t, got.tableWidth(), tt.width)
+ }
+ if tt.wantCompact {
+ assert.Equal(t, 11, got.started)
+ assert.Equal(t, len("qwen-run-001"), got.mlflow, "MLflow is sized to its widest label")
+ } else {
+ // A terminal that fits today's table gets today's layout.
+ assert.Equal(t, natural.fitWidth(tt.width), got)
+ assert.Equal(t, mlflowColWidth, got.mlflow)
+ }
+ })
+ }
+}
+
+// fullLabelListRows is wideListRows with MLflow labels at mlflowColWidth, the
+// widest list.go produces and the usual case for a run named by MLflow. With
+// these labels compacting saves only the 8 cells of Started.
+func fullLabelListRows() []listRow {
+ rows := wideListRows()
+ for i := range rows {
+ if rows[i].MLflowURL != "-" {
+ rows[i].MLflowLabel = "…ne-2026-10-23-lr-3e-5"
+ if lipgloss.Width(rows[i].MLflowLabel) != mlflowColWidth {
+ panic("fixture label is not mlflowColWidth cells wide")
+ }
+ }
+ }
+ return rows
+}
+
+func TestLayoutListColsFullLabels(t *testing.T) {
+ rows := fullLabelListRows()
+ tests := []struct {
+ name string
+ width int
+ wantExperiment int
+ wantUser int
+ wantHidden []string
+ }{
+ {name: "133 compacts Started only", width: 133, wantExperiment: 13, wantUser: 14},
+ {name: "126 is the narrowest compact fit", width: 126, wantExperiment: experimentMinWidth, wantUser: userMinWidth},
+ {name: "125 hides Accelerators", width: 125, wantExperiment: 16, wantUser: 17, wantHidden: []string{"Accelerators"}},
+ {name: "120 hides Accelerators", width: 120, wantExperiment: 14, wantUser: 14, wantHidden: []string{"Accelerators"}},
+ {name: "100 hides MLflow and User too", width: 100, wantExperiment: 34, wantHidden: []string{"MLflow", "User", "Accelerators"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := layoutListCols(rows, tt.width)
+ assert.True(t, got.compactStarted)
+ assert.Equal(t, mlflowColWidth, got.mlflow)
+ assert.Equal(t, tt.wantExperiment, got.experiment)
+ if got.shown(columnUser) {
+ assert.Equal(t, tt.wantUser, got.user)
+ }
+ assert.Equal(t, tt.wantHidden, got.hiddenNames())
+ assert.LessOrEqual(t, got.tableWidth(), tt.width)
+ })
+ }
+}
+
+func TestLayoutListColsMovesOneWayAsPagesLoad(t *testing.T) {
+ // Loading more rows can only widen the natural columns, so a column that
+ // is hidden stays hidden and a compact layout stays compact.
+ rows := wideListRows()
+ for _, width := range []int{80, 100, 120, 133, 160} {
+ first := layoutListCols(rows[1:], width)
+ all := layoutListCols(rows, width)
+ assert.Equal(t, first.hidden, first.hidden&all.hidden, "width %d: a hidden column reappeared", width)
+ if first.compactStarted {
+ assert.True(t, all.compactStarted, "width %d: Started widened again", width)
+ }
+ }
+ // The sample does cross a tier: the second page hides User at 100 columns.
+ assert.Equal(t, []string{"Accelerators"}, layoutListCols(rows[1:], 100).hiddenNames())
+ assert.Equal(t, []string{"User", "Accelerators"}, layoutListCols(rows, 100).hiddenNames())
+}
+
+func TestLayoutListColsCapsMLflowAtColumnWidth(t *testing.T) {
+ // A label wider than the column (none is built that way) never widens it.
+ rows := wideListRows()
+ rows[0].MLflowLabel = strings.Repeat("x", mlflowColWidth+5)
+ assert.Equal(t, mlflowColWidth, layoutListCols(rows, 120).mlflow)
+}
+
+func TestLayoutListColsMLflowFloorIsHeader(t *testing.T) {
+ // With no MLflow links the compact column is just wide enough for its header.
+ rows := wideListRows()
+ for i := range rows {
+ rows[i].MLflowURL, rows[i].MLflowLabel = "-", "-"
+ }
+ got := layoutListCols(rows, 120)
+ assert.True(t, got.compactStarted)
+ assert.Equal(t, len("MLflow"), got.mlflow)
+}
+
+func TestStartedCompact(t *testing.T) {
+ assert.Equal(t, "-", startedCompact(listRow{}))
+ assert.Equal(t, "06-05 17:32", startedCompact(listRow{StartedAt: new("2026-06-05T17:32:39.000000+00:00")}))
+ assert.Equal(t, "2026-06", startedCompact(listRow{StartedAt: new("2026-06")}))
+ assert.Equal(t, "Mon Jan 2 15:04:05 2006", startedCompact(listRow{StartedAt: new("Mon Jan 2 15:04:05 2006")}))
+}
+
+func TestHiddenNote(t *testing.T) {
+ assert.Empty(t, hiddenNote(nil, true))
+ assert.Empty(t, hiddenNote(nil, false))
+ assert.Equal(t, " · hidden: User, Accelerators", hiddenNote([]string{"User", "Accelerators"}, true))
+ assert.Equal(t, " · 1 column hidden", hiddenNote([]string{"Accelerators"}, false))
+ assert.Equal(t, " · 2 columns hidden", hiddenNote([]string{"User", "Accelerators"}, false))
+}
+
+func TestRenderHidesColumns(t *testing.T) {
+ r, _ := cmdio.NewRenderer(cmdio.MockDiscard(t.Context()), io.Discard)
+ styles := newListStyles(r)
+ rows := wideListRows()
+ cols := layoutListCols(rows, 80)
+ require.Equal(t, []string{"MLflow", "User", "Accelerators"}, cols.hiddenNames())
+
+ header := styles.renderHeader(cols)
+ assert.Equal(t, cols.tableWidth(), ansi.StringWidth(header))
+ for _, name := range cols.hiddenNames() {
+ assert.NotContains(t, header, name)
+ }
+ assert.Contains(t, header, "Progress")
+
+ for _, selected := range []bool{false, true} {
+ row := styles.renderRow(cols, rows[0], selected, true)
+ assert.LessOrEqual(t, ansi.StringWidth(row), cols.tableWidth())
+ assert.Contains(t, row, "10-23 17:32")
+ assert.NotContains(t, row, "qwen-run-001")
+ assert.NotContains(t, row, "8x H100")
+ assert.NotContains(t, row, "runs/04c41514fbb0", "a hidden MLflow column carries no hyperlink")
+ }
+ assert.True(t, strings.HasPrefix(styles.renderRow(cols, rows[0], true, false), "▸"))
+}
+
+func TestListModelNarrowTerminalHidesColumns(t *testing.T) {
+ r, _ := cmdio.NewRenderer(cmdio.MockDiscard(t.Context()), io.Discard)
+ f := &runFetcher{ctx: t.Context(), exhausted: true}
+ m := newListModel(r, f, wideListRows(), false)
+
+ view := func(width int) []string {
+ next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: 30})
+ m = next.(listModel)
+ lines := strings.Split(strings.TrimRight(m.View(), "\n"), "\n")
+ require.Len(t, lines, 4) // header, two rows, hint
+ for _, line := range lines {
+ assert.LessOrEqual(t, ansi.StringWidth(line), width, line)
+ }
+ return lines
+ }
+
+ lines := view(80)
+ assert.NotContains(t, lines[0], "User")
+ assert.Contains(t, lines[3], "↑/↓ ←/→ ↵ i L q · row 1/2 · hidden: MLflow, User, Accelerators")
+
+ lines = view(100)
+ assert.Contains(t, lines[0], "MLflow")
+ assert.Contains(t, lines[3], "hidden: User, Accelerators")
+
+ lines = view(120)
+ assert.Contains(t, lines[0], "Accelerators")
+ assert.Contains(t, lines[1], "10-23 17:32")
+ assert.Contains(t, lines[3], "↑/↓ navigate · ←/→ page · ↵ mlflow · i info · L logs · q quit · row 1/2")
+ assert.NotContains(t, lines[3], "hidden")
+
+ // Widening past today's threshold restores the full timestamp.
+ lines = view(160)
+ assert.Contains(t, lines[1], "2026-10-23T17:32:39")
+}
+
+func TestListModelHintNamesHiddenColumnsWithFullLegend(t *testing.T) {
+ r, _ := cmdio.NewRenderer(cmdio.MockDiscard(t.Context()), io.Discard)
+ f := &runFetcher{ctx: t.Context(), exhausted: true}
+ m := newListModel(r, f, fullLabelListRows(), false)
+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
+ m = next.(listModel)
+ lines := strings.Split(strings.TrimRight(m.View(), "\n"), "\n")
+ require.Len(t, lines, 4)
+ assert.NotContains(t, lines[0], "Accelerators")
+ assert.Contains(t, lines[0], "MLflow")
+ assert.Equal(t, hintLegend+" · row 1/2 · hidden: Accelerators", lines[3])
+ assert.LessOrEqual(t, ansi.StringWidth(lines[3]), 120)
+}
+
+func TestListModelHintFitsNarrowTerminal(t *testing.T) {
+ r, _ := cmdio.NewRenderer(cmdio.MockDiscard(t.Context()), io.Discard)
+ // Enough rows for a three-digit counter, all with wide values so every
+ // hideable column goes at the narrowest widths.
+ var rows []listRow
+ for len(rows) < 120 {
+ rows = append(rows, wideListRows()...)
+ }
+ for _, width := range []int{40, 46, 70, 75, 80, 100} {
+ for _, state := range []string{"idle", "loading", "failed"} {
+ t.Run(fmt.Sprintf("%d/%s", width, state), func(t *testing.T) {
+ f := &runFetcher{ctx: t.Context(), exhausted: true}
+ m := newListModel(r, f, rows, false)
+ next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: 30})
+ m = next.(listModel)
+ switch state {
+ case "loading":
+ m.loading = true
+ case "failed":
+ m.loadErr = errors.New("boom")
+ }
+ m.cursor = len(rows) - 1
+ hint := m.renderHint()
+ assert.LessOrEqual(t, ansi.StringWidth(hint), width, hint)
+ // The row counter and paging state come first, so they survive
+ // every step down.
+ assert.Contains(t, hint, "row 120/120")
+ switch state {
+ case "loading":
+ assert.Contains(t, hint, "(loading…)")
+ case "failed":
+ assert.Contains(t, hint, "(load failed)")
+ }
+ // Only the last-resort truncation, which needs a terminal
+ // narrower than the table's own minimum, can cut the note or
+ // make the idle and loading hints differ. Otherwise the variant
+ // is chosen for the widest counter and the loading state, so it
+ // doesn't change while a page loads.
+ idle, loading := m, m
+ idle.loading, idle.loadErr = false, nil
+ loading.loading, loading.loadErr = true, nil
+ loadingHint := loading.renderHint()
+ if strings.HasSuffix(loadingHint, "…") || strings.HasSuffix(hint, "…") {
+ assert.Less(t, width, 48, hint)
+ return
+ }
+ assert.True(t, strings.Contains(hint, "hidden: ") || strings.Contains(hint, "columns hidden"), hint)
+ assert.Equal(t, idle.renderHint(), strings.Replace(loadingHint, " (loading…)", "", 1))
+ })
+ }
+ }
+}
+
+func TestListModelRelayoutsLoadedPageInNarrowTerminal(t *testing.T) {
+ r, _ := cmdio.NewRenderer(cmdio.MockDiscard(t.Context()), io.Discard)
+ rows := wideListRows()
+ f := &runFetcher{ctx: t.Context(), exhausted: true}
+ m := newListModel(r, f, rows[1:], false)
+ next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
+ m = next.(listModel)
+ require.Equal(t, []string{"Accelerators"}, m.cols.hiddenNames())
+
+ next, _ = m.Update(moreRowsMsg{rows: rows[:1]})
+ m = next.(listModel)
+ assert.Equal(t, []string{"User", "Accelerators"}, m.cols.hiddenNames())
+ for line := range strings.SplitSeq(strings.TrimRight(m.View(), "\n"), "\n") {
+ assert.LessOrEqual(t, ansi.StringWidth(line), 100, line)
+ }
+}
+
+func TestListModelEnterOpensMLflowLogsWhenColumnHidden(t *testing.T) {
+ ctx, stderr := cmdio.NewTestContextWithStderr(t.Context())
+ ctx = env.Set(ctx, "BROWSER", "none")
+ r, _ := cmdio.NewRenderer(ctx, io.Discard)
+ f := &runFetcher{ctx: ctx, w: newTestWorkspaceClient(t, "https://x.test")}
+ m := newListModel(r, f, testListRows(), false)
+
+ next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
+ m = next.(listModel)
+ require.False(t, m.cols.shown(columnMLflow))
+
+ _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ require.NotNil(t, cmd)
+ cmd()
+ assert.Contains(t, stderr.String(), "https://h/ml/experiments/E/runs/04c41514fbb0/logs")
+}