From 9ad045cd21d76fc0bea0b67dfd95cb0491d97689 Mon Sep 17 00:00:00 2001 From: Dima Kotlyarov <4886215+dimk0@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:40:48 +0000 Subject: [PATCH 1/4] air: Fit `air list` to narrow terminals by compacting and then hiding columns After #7583 the interactive table shrinks Experiment and User to their floors, but the other columns keep fixed widths, so below about 134 columns the rightmost columns are still cut off. When the table is still too wide at the floors, two more tiers apply: 1. Compact: Started becomes `MM-DD HH:MM` and the MLflow column is sized to its widest stored label instead of a fixed 22 cells. Labels are not re-truncated, so they keep their end and retry note (#7582). 2. Hide: whole columns are dropped lowest priority first (Accelerators, User, MLflow, Progress) until the table fits. The hint line names the hidden columns; when the full key legend and that note do not both fit, the legend shortens to its keys. After each step the spare cells go back to Experiment and User. Enter still opens the row's MLflow link when the MLflow column is hidden. The one-shot table (piped, NO_COLOR, --limit) and terminals wide enough for today's table render exactly as before. Verified with unit tests at 80, 100, 120, 133, 160 and 200 columns and by running the CLI in a pty at those widths against e2-dogfood. CNXT-2619 Co-authored-by: Isaac --- cmd/air/list_tui.go | 25 +++- cmd/air/list_tui_render.go | 237 +++++++++++++++++++++++++++++++------ cmd/air/list_tui_test.go | 141 ++++++++++++++++++++++ 3 files changed, 358 insertions(+), 45 deletions(-) diff --git a/cmd/air/list_tui.go b/cmd/air/list_tui.go index 53ae3b3980f..1420d089c07 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,7 @@ 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) + 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 +361,31 @@ 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). +// renderHint is the faint one-line key legend, with the cursor position, the +// columns a narrow terminal has hidden, and the paging state (loading / load +// failed). When the full legend and the hidden-column note don't both fit the +// terminal, the legend shortens to its keys so the note stays visible. 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)) + hint := m.hintText("↑/↓ navigate · ←/→ page · ↵ mlflow · i info · L logs · q quit") + if m.width > 0 && lipgloss.Width(hint) > m.width { + hint = m.hintText("↑/↓ ←/→ ↵ i L q") + } + return faint.Render(hint) +} + +func (m listModel) hintText(legend string) string { + hint := fmt.Sprintf("%s · row %d/%d", legend, m.cursor+1, len(m.rows)) + if hidden := m.cols.hiddenNames(); len(hidden) > 0 { + hint += " · hidden: " + strings.Join(hidden, ", ") + } switch { case m.loadErr != nil: hint += " (load failed)" case m.loading: hint += " (loading…)" } - return faint.Render(hint) + return 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..8c8b54df671 100644 --- a/cmd/air/list_tui_render.go +++ b/cmd/air/list_tui_render.go @@ -35,16 +35,48 @@ 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 +) + +// numListColumns is how many columns the table has. +const numListColumns = int(columnAccel) + 1 + +// listColumnNames are the header titles, indexed by listColumn. +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: the layout of the one-shot +// table, and the starting point for fitting the interactive one. func computeListCols(rows []listRow) listCols { c := listCols{ runID: len("Run ID"), @@ -53,6 +85,7 @@ func computeListCols(rows []listRow) listCols { started: len("Started"), duration: len("Duration"), progress: len("Progress"), + mlflow: mlflowColWidth, user: len("User"), accel: len("Accelerators"), } @@ -76,12 +109,58 @@ const ( userMinWidth = 8 ) -// tableWidth is the full width of a rendered row: every column plus the gutter -// and the two-cell separators. +// width is the cell width of col. +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 +} + +// shown reports whether col is part of the rendered table. +func (c listCols) shown(col listColumn) bool { + return c.hidden&(1<