From d61c2867f7f7fdc0d7697c22b07ae0e8332c8ba2 Mon Sep 17 00:00:00 2001 From: Jay Hesselberth Date: Sun, 30 Aug 2026 05:49:14 -0600 Subject: [PATCH 1/2] fix(zellij): pair the panel's resource rows so four GPUs fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monitor panel is five rows: the job strip, cpu, mem, and two left over. A four-GPU node therefore showed gpu0 and gpu1 and silently dropped the rest — the two GPUs most likely to be the ones idling. Growing the pane was the obvious fix and the wrong one: the panel is a fixed-height pane zellij will not resize, and every row it takes is a row off the shell for CPU-only jobs too. The width was going spare instead. In a pane 122 columns or wider the resources now go two to a row — cpu beside mem, then the GPUs in pairs — so all four fit with the trend row still spare. The threshold is where each half still gets a 12-cell bar; narrower panes keep the stacked layout, and its 24-cell bars, exactly as before, so nothing changes for anyone whose pane was already too narrow for a second column. Both halves share one grid — the right column's label, bar and slash land in the same place on every paired row — and the layout depends only on the pane's width, never on the selected host, so the bars stay put as the selection moves between a CPU job and a GPU job. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VSoaHVi6BUcdLXySPQDn4G --- CHANGELOG.md | 8 ++ crates/sint-zellij/src/render.rs | 182 ++++++++++++++++++++++++++++--- crates/sint-zellij/src/state.rs | 3 +- docs/usage.md | 5 + 4 files changed, 179 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e62b4ba..6efe8cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to ## [Unreleased] +### Fixed + +- The monitor panel showed only `gpu0` and `gpu1` on a four-GPU node: the + panel is five rows, and the strip, cpu and mem take three. In a pane 122 + columns or wider the resources now go two to a row — `cpu` beside `mem`, + then the GPUs in pairs — so all four fit with the trend row still spare. + Narrower panes keep the stacked layout, and its bars, as before. + ## [1.1.0] - 2026-08-29 ### Added diff --git a/crates/sint-zellij/src/render.rs b/crates/sint-zellij/src/render.rs index 4fc67ad..2617f46 100644 --- a/crates/sint-zellij/src/render.rs +++ b/crates/sint-zellij/src/render.rs @@ -506,6 +506,12 @@ pub fn job_strip(st: &State, cols: usize) -> String { /// for, then the cpu trend if any row is still going spare. The process /// table is not here — `t` opens the full `sinteractive monitor` TUI in a /// floating pane, which scrolls and sorts the way a top does. +/// +/// A wide pane lays the resources out two to a row — cpu beside mem, then +/// the GPUs in pairs — so a four-GPU node fits its five rows with one to +/// spare instead of losing gpu2 and gpu3 off the bottom. The threshold is +/// the width at which both halves still get a bar worth reading +/// (`MIN_WIDE_BAR` cells); below it the rows stack as before. pub fn panel_lines(st: &State, rows: usize, cols: usize) -> Vec { let c = colors(st.theme); let mut out = Vec::new(); @@ -520,20 +526,33 @@ pub fn panel_lines(st: &State, rows: usize, cols: usize) -> Vec { return out; }; out.push(job_strip(st, cols)); + let per_row = if cols >= 2 * (WIDEST_LABEL + 1 + MIN_WIDE_BAR + ROW_TAIL) + GUTTER { + 2 + } else { + 1 + }; + // cpu and mem come first; the GPUs get whatever rows are left after + // them, `per_row` to each. + let gpu_slots = rows.saturating_sub(out.len() + 2usize.div_ceil(per_row)) * per_row; + let shown = &h.gpus[..h.gpus.len().min(gpu_slots)]; // The resource rows share one grid — label, bar, percentage, then the // row's own detail — so a `gpu0` under `cpu` and `mem` does not set its // bar one cell to the right of theirs. The label column is as wide as // the widest label that can turn up (`trend`, or a two-digit GPU), not // the widest on this host, so the grid stays put as the selection moves - // between a CPU job and a GPU job. - let gpu_rows = h.gpus.len().min(rows.saturating_sub(out.len() + 2)); - let lw = h.gpus[..gpu_rows] + // between a CPU job and a GPU job. Both halves of a paired row use the + // same grid, so mem's slash sits under gpu1's as it does under gpu0's + // when the rows stack. + let lw = shown .iter() .map(|g| format!("gpu{}", g.index).len()) - .fold("trend".len(), usize::max); - // Everything after the bar, at its widest: ` 100% 31G / 40G 61°C 240W ` - // and the GPU row's memory mini-bar. - let bw = 24usize.min(cols.saturating_sub(lw + 1 + 34 + GPU_MEM_BAR).max(5)); + .fold(WIDEST_LABEL, usize::max); + let cw = if per_row == 2 { + (cols - GUTTER) / 2 + } else { + cols + }; + let bw = MAX_BAR.min(cw.saturating_sub(lw + 1 + ROW_TAIL).max(MIN_BAR)); let label = |s: &str| format!("{}{s:3}%{RESET}", fg(c.text)); // `used / total`, the amount right-aligned and the total padded on the @@ -549,13 +568,8 @@ pub fn panel_lines(st: &State, rows: usize, cols: usize) -> Vec { mb_to_g(total) ) }; - let stale = if h.age_secs > 30 { - format!(" {}{}s old{RESET}", fg(c.warn), h.age_secs) - } else { - String::new() - }; - out.push(format!( - "{} {} {} {}of{RESET} {}{}{RESET} {}·{RESET} {}load{RESET} {}{:.1}{RESET}{stale}", + let mut cells = vec![format!( + "{} {} {} {}of{RESET} {}{}{RESET} {}·{RESET} {}load{RESET} {}{:.1}{RESET}", label("cpu"), bar(h.cpu_pct, bw, c.ok, c.track), pct(h.cpu_pct), @@ -566,16 +580,16 @@ pub fn panel_lines(st: &State, rows: usize, cols: usize) -> Vec { fg(c.dim), fg(c.text), h.load1, - )); + )]; let mem_pct = pct_of(h.mem_used_mb, h.mem_alloc_mb); - out.push(format!( + cells.push(format!( "{} {} {} {}", label("mem"), bar(mem_pct, bw, c.accent, c.track), pct(mem_pct), used_of(h.mem_used_mb, h.mem_alloc_mb, 0) )); - for g in &h.gpus[..gpu_rows] { + for g in shown { let mem_pct = pct_of(g.mem_used_mb, g.mem_total_mb); let extra = [ g.temp_c.map(|t| format!("{t}°C")), @@ -585,7 +599,7 @@ pub fn panel_lines(st: &State, rows: usize, cols: usize) -> Vec { .flatten() .collect::>() .join(" "); - out.push(format!( + cells.push(format!( "{} {} {} {} {}{:<10}{RESET} {}", label(&format!("gpu{}", g.index)), bar(g.util_pct, bw, c.warn, c.track), @@ -596,6 +610,13 @@ pub fn panel_lines(st: &State, rows: usize, cols: usize) -> Vec { bar(mem_pct, GPU_MEM_BAR, c.accent, c.track) )); } + out.extend(cells.chunks(per_row).map(|pair| beside(pair, cw))); + // The sample's age rides on the first resource row: the cpu row when + // they stack, `cpu | mem` when they pair — either way the row with the + // least of its own to say. + if h.age_secs > 30 { + out[1].push_str(&format!(" {}{}s old{RESET}", fg(c.warn), h.age_secs)); + } // A CPU-only job leaves rows over: spend them on where the load has // been, which is the one thing a bar cannot say. if out.len() < rows && h.cpu_history.len() > 1 { @@ -609,9 +630,43 @@ pub fn panel_lines(st: &State, rows: usize, cols: usize) -> Vec { out } +/// One or two resource cells as a row: the first padded to `cw`, then the +/// gutter, then the second — so a bar in the right column starts in the +/// same place on every row. +fn beside(cells: &[String], cw: usize) -> String { + match cells { + [left, right] => { + let pad = cw.saturating_sub(visible_width(left)) + GUTTER; + format!("{left}{}{right}", " ".repeat(pad)) + } + [one] => one.clone(), + _ => String::new(), + } +} + +/// Widest label a resource row can carry: `trend`, or a two-digit GPU. +const WIDEST_LABEL: usize = "trend".len(); + +/// Everything after a resource row's bar, at its widest — the GPU row's +/// ` 100% 31G / 40G 61°C 240W ` and its memory mini-bar. +const ROW_TAIL: usize = 34 + GPU_MEM_BAR; + +/// The bar a resource row gets when there is room, and the least it is ever +/// cut to when there is not. +const MAX_BAR: usize = 24; +const MIN_BAR: usize = 5; + +/// The bar each half of a paired row must be able to hold before the panel +/// pairs rows at all: any thinner and the bars stop being readable, so a +/// pane narrower than that stacks them and shows fewer GPUs instead. +const MIN_WIDE_BAR: usize = 12; + /// Width of the memory mini-bar at the end of a GPU row. const GPU_MEM_BAR: usize = 8; +/// Columns between the two halves of a paired row. +const GUTTER: usize = 2; + /// Render the monitor-panel pane (the `view=monitor` instance): the rule and /// the panel rows, no status line. pub fn render_panel(st: &State, rows: usize, cols: usize) -> String { @@ -883,6 +938,97 @@ mod tests { } } + #[test] + fn a_wide_pane_pairs_the_rows_so_four_gpus_fit() { + let mut st = state(); + st.is_panel = true; + st.panel_open = true; + let mut m = st.msg.clone(); + let g0 = m.hosts[0].gpus[0].clone(); + m.hosts[0].gpus = (0..4) + .map(|i| GpuLine { + index: i, + util_pct: 20 * (i as u8 + 1), + ..g0.clone() + }) + .collect(); + st.apply_msg(m); + let plain = |st: &State, cols: usize| -> Vec { + panel_lines(st, PANEL_ROWS, cols) + .iter() + .map(|l| strip_ansi(l)) + .collect() + }; + + // Stacked, the panel runs out of rows at gpu1 and has no trend. + let narrow = plain(&st, 100); + assert_eq!(narrow.len(), PANEL_ROWS); + assert!(narrow[3].starts_with("gpu0") && narrow[4].starts_with("gpu1")); + assert!(!narrow.iter().any(|l| l.contains("gpu2")), "{narrow:?}"); + + // Paired, all four fit with a row left for the trend, and the right + // column is its own grid: mem, gpu1 and gpu3 start in one place. + let wide = plain(&st, 160); + assert_eq!(wide.len(), PANEL_ROWS, "{wide:?}"); + assert!( + wide[1].starts_with("cpu") && wide[1].contains("mem"), + "{:?}", + wide[1] + ); + assert!( + wide[2].starts_with("gpu0") && wide[2].contains("gpu1"), + "{:?}", + wide[2] + ); + assert!( + wide[3].starts_with("gpu2") && wide[3].contains("gpu3"), + "{:?}", + wide[3] + ); + assert!(wide[4].starts_with("trend"), "{:?}", wide[4]); + let right = |l: &str, label: &str| l[..l.find(label).unwrap()].chars().count(); + assert_eq!(right(&wide[1], "mem"), right(&wide[2], "gpu1")); + assert_eq!(right(&wide[1], "mem"), right(&wide[3], "gpu3")); + let bars = |l: &str| l.match_indices(['█', '░']).count(); + assert_eq!(bars(&wide[1]), 2 * MAX_BAR, "two full bars: {:?}", wide[1]); + for l in &wide { + assert!(visible_width(l) <= 160, "{l:?}"); + } + + // An odd GPU leaves the right half of its row empty, not padded. + let mut m = st.msg.clone(); + m.hosts[0].gpus.truncate(3); + st.apply_msg(m); + let odd = plain(&st, 160); + assert!( + odd[3].starts_with("gpu2") && !odd[3].ends_with(' '), + "{:?}", + odd[3] + ); + assert!(odd[4].starts_with("trend"), "{:?}", odd[4]); + + // At the threshold the bars are the narrowest worth pairing; one + // column short of it the rows stack again, with room for a fat bar. + let at = 2 * (WIDEST_LABEL + 1 + MIN_WIDE_BAR + ROW_TAIL) + GUTTER; + let edge = plain(&st, at); + assert!(edge[2].contains("gpu1"), "{:?}", edge[2]); + assert_eq!( + bars(&edge[2]) - 2 * GPU_MEM_BAR, + 2 * MIN_WIDE_BAR, + "{:?}", + edge[2] + ); + for l in &edge { + assert!(visible_width(l) <= at, "{l:?}"); + } + let under = plain(&st, at - 1); + assert!( + under[3].starts_with("gpu0") && !under[2].contains("gpu"), + "{under:?}" + ); + assert_eq!(bars(&under[1]), MAX_BAR, "{:?}", under[1]); + } + #[test] fn the_strip_scrolls_to_keep_the_selection_in_view() { let mut st = state(); diff --git a/crates/sint-zellij/src/state.rs b/crates/sint-zellij/src/state.rs index 1abf9d2..c8155a8 100644 --- a/crates/sint-zellij/src/state.rs +++ b/crates/sint-zellij/src/state.rs @@ -24,7 +24,8 @@ pub enum ThemeMode { } /// Content rows the monitor panel draws below its accent rule: the job -/// strip, cpu, mem, and whatever GPU or history rows fit after them. The +/// strip, cpu, mem, and whatever GPU or history rows fit after them — two +/// resources to a row when the pane is wide enough, so four GPUs fit. The /// pane itself is one row taller (see `layouts/sint-panel.kdl`). pub const PANEL_ROWS: usize = 5; diff --git a/docs/usage.md b/docs/usage.md index fb83c43..7bd92a7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -194,6 +194,11 @@ GPU. A job with no GPU spends the spare row on where its load has been. Jobs on other nodes are sampled over ssh every ten seconds; one on this node is sampled here. +In a pane 122 columns or wider the resources go two to a row — `cpu` +beside `mem`, then the GPUs in pairs — so a four-GPU node fits with a row +to spare. Narrower than that the rows stack as below, and a third and +fourth GPU fall off the bottom. + ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 147845 mywork · 246422 sint-mods 1/2 ←→ job · t top · esc shell · x close From 243e912e54b89f454a28d8adf911c8a5e3289d3c Mon Sep 17 00:00:00 2001 From: Jay Hesselberth Date: Sun, 30 Aug 2026 05:57:13 -0600 Subject: [PATCH 2/2] fix(zellij): draw the trend the bars' width, under them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trend sparkline was as wide as the pane allowed, which in practice meant as wide as the sixty-sample history: it ran well past the cpu and mem rows above it and, with the rows now paired, out from under the left column towards the right one. A sparkline is one more row of the bar column, so it is now the bars' width and sits under them — the last 24 samples on a wide pane, fewer as the bar gives way — in both layouts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VSoaHVi6BUcdLXySPQDn4G --- CHANGELOG.md | 3 +++ crates/sint-zellij/src/render.rs | 22 ++++++++++++++++++++-- docs/usage.md | 3 ++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6efe8cb..5cd35b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to columns or wider the resources now go two to a row — `cpu` beside `mem`, then the GPUs in pairs — so all four fit with the trend row still spare. Narrower panes keep the stacked layout, and its bars, as before. +- The panel's `trend` sparkline is the bars' width and sits under them — + the last 24 samples on a wide pane — instead of running out to wherever + the sixty-sample history ended, well past the rows above it. ## [1.1.0] - 2026-08-29 diff --git a/crates/sint-zellij/src/render.rs b/crates/sint-zellij/src/render.rs index 2617f46..1fa53ab 100644 --- a/crates/sint-zellij/src/render.rs +++ b/crates/sint-zellij/src/render.rs @@ -618,12 +618,15 @@ pub fn panel_lines(st: &State, rows: usize, cols: usize) -> Vec { out[1].push_str(&format!(" {}{}s old{RESET}", fg(c.warn), h.age_secs)); } // A CPU-only job leaves rows over: spend them on where the load has - // been, which is the one thing a bar cannot say. + // been, which is the one thing a bar cannot say. The sparkline is the + // bars' width and sits under them, one more row of the same column, + // rather than running out past the rows above it to wherever the + // history happens to end. if out.len() < rows && h.cpu_history.len() > 1 { out.push(format!( "{} {}", label("trend"), - sparkline(&h.cpu_history, cols.saturating_sub(lw + 1)) + sparkline(&h.cpu_history, bw) )); } out.truncate(rows); @@ -928,6 +931,15 @@ mod tests { "the trend starts under the bars: {:?}", cpu_only[3] ); + let mut m = st.msg.clone(); + m.hosts[0].cpu_history = (0..60).map(|i| i % 100).collect(); + st.apply_msg(m); + let trend = strip_ansi(&panel_lines(&st, PANEL_ROWS, 100)[3]); + assert_eq!( + visible_width(&trend), + bar_col(cpu) + MAX_BAR, + "the trend ends with the bars, however long the history: {trend:?}" + ); // Narrower panes still fit: the bar gives way first. (Below ~53 // columns the fixed text alone is wider than the pane, as before.) @@ -1021,6 +1033,12 @@ mod tests { for l in &edge { assert!(visible_width(l) <= at, "{l:?}"); } + assert_eq!( + visible_width(&edge[4]) - WIDEST_LABEL - 1, + MIN_WIDE_BAR.min(st.msg.hosts[0].cpu_history.len()), + "the trend shrinks with the bars: {:?}", + edge[4] + ); let under = plain(&st, at - 1); assert!( under[3].starts_with("gpu0") && !under[2].contains("gpu"), diff --git a/docs/usage.md b/docs/usage.md index 7bd92a7..45da80b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -190,7 +190,8 @@ to go. `Ctrl+b m` opens a six-row panel between the shell and the bar: a strip of every running job launched from this session (this one first), then bars for CPU and memory against the selected job's cgroup limits, and a row per -GPU. A job with no GPU spends the spare row on where its load has been. +GPU. A job with no GPU spends the spare row on where its load has been, a +sparkline the width of the bars above it. Jobs on other nodes are sampled over ssh every ten seconds; one on this node is sampled here.