josie / alder-tools

Add TUI picker (Stability/Bench/Core-cycle/Custom) + loop support

- src/presets.rs: three preset builders + AVAILABLE_WORKLOADS list.
  stability() = 3 ISA rails, 5 min each, looped 5x for ~1 hr.
  stability_core_cycle(per_core_secs) = same 3 workloads on one core at
  a time, cycling all 16 logical cores twice (tests peak single-core
  clocks on chips with per-core load-based multipliers). bench() = 3
  scored workloads, 60s each, single pass.
- src/config.rs: added loop_count field (1 = single pass, 0 = infinite).
  Config now derives Clone (the editor needs to clone it on Start).
- src/run.rs: loop support. Run tracks loops_left + loop_index; on
  SweepDone with loops remaining, resets current to 0 + re-enters
  Cooldown. loop_index() + loop_count() accessors for the TUI header.
  Two new tests: loop_resets_current_and_decrements, loop_infinite_never_done.
- src/workload.rs: YCruncher + SevenZip now honor the cores field via
  taskset -c <range> wrapper (full-range 0-15/all skips the wrapper).
  taskset_command() helper accepts OsStr so it works with both &str (7z)
  and &PathBuf (y-cruncher's canonicalized variant path). StressNg
  already used --taskset internally. Single-core pinning (cores=0)
  now works for all three spawned impls — the foundation for the
  core-cycle preset.
- src/ui.rs: rewritten with three modes. Picker (home screen) lists
  Stability / Bench / Core-cycle / Custom with j/k + arrow nav + Enter.
  Editor (custom preset) edits the workload list (toggle name, edit
  duration/cores, add/del rows, j/k reorder), globals (cooldown, loop
  count), then Start to launch. Sweep mode preserved from prior session
  with loop display in the header (loop N/M or loop N/∞). Esc cancels
  editor back to picker; q quits the whole TUI from any mode.
- src/main.rs: --preset stability|bench|core-cycle arg for scripting
  (skips the picker, launches the preset sweep directly). Default mode
  is still the TUI picker.
- Verified live via forkpty recipe: picker renders + j/k nav + Enter on
  Custom opens the editor (all editor strings FOUND in the capture).
  52/52 tests; clippy clean.

b79a210fe72a81344beed9c044eae7884ce775ad
josie <administrator@josie-c.com> · 2026-09-01T22:02 · browse files at this commit

parents: 8db8144

diff --git a/src/config.rs b/src/config.rs
index 8cdfa5b..2d9c2c1 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -22,6 +22,7 @@ pub struct WorkloadSpec {
     pub cores: String,
 }
 
+#[derive(Clone, Debug)]
 pub struct Config {
     /// Workloads to cycle through, in order. One sweep run = each entry
     /// once, with `cool_secs` cooldown between.
@@ -30,6 +31,11 @@ pub struct Config {
     pub poll_ms: u64,
     /// Cooldown between workloads, seconds (idle baseline re-set).
     pub cool_secs: u64,
+    /// Number of times to repeat the full sweep. 1 = single pass, 0 =
+    /// infinite (loop forever until the user quits). The run loop
+    /// resets + re-runs on SweepDone when loops remain. Used by the
+    /// stability preset's 1-hour target + the custom editor's loop knob.
+    pub loop_count: u32,
     /// Where to write JSON reports. Created on first run if missing.
     pub report_dir: PathBuf,
     /// Where external binaries live (y-cruncher). Defaults to ./bin.
@@ -44,6 +50,7 @@ impl Default for Config {
             workloads: default_workloads(),
             poll_ms: 250,
             cool_secs: 15,
+            loop_count: 1,
             report_dir: PathBuf::from("runs"),
             bin_dir: PathBuf::from("bin"),
             loaded_path: None,
@@ -92,6 +99,7 @@ fn apply_line(cfg: &mut Config, line: &str) {
     match key {
         "poll_ms" => cfg.poll_ms = val.parse().unwrap_or(cfg.poll_ms).max(50),
         "cool_secs" => cfg.cool_secs = val.parse().unwrap_or(cfg.cool_secs),
+        "loop_count" => cfg.loop_count = val.parse().unwrap_or(cfg.loop_count),
         "report_dir" => cfg.report_dir = PathBuf::from(val),
         "bin_dir" => cfg.bin_dir = PathBuf::from(val),
         "workloads" => cfg.workloads = parse_workload_list(val),
@@ -214,8 +222,10 @@ mod tests {
         let mut cfg = Config::default();
         apply_line(&mut cfg, "poll_ms = 500");
         apply_line(&mut cfg, "cool_secs = 30");
+        apply_line(&mut cfg, "loop_count = 3");
         assert_eq!(cfg.poll_ms, 500);
         assert_eq!(cfg.cool_secs, 30);
+        assert_eq!(cfg.loop_count, 3);
     }
 
     #[test]
diff --git a/src/main.rs b/src/main.rs
index 31f1c81..d75fc23 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,5 @@
 mod config;
+mod presets;
 mod report;
 mod run;
 mod ui;
@@ -17,13 +18,33 @@ fn main() {
         headless_run(cfg);
         return;
     }
+    if let Some(idx) = args.iter().position(|a| a == "--preset") {
+        if let Some(name) = args.get(idx + 1) {
+            match name.as_str() {
+                "stability" => {
+                    ui::run_tui(presets::stability());
+                    return;
+                }
+                "bench" => {
+                    ui::run_tui(presets::bench());
+                    return;
+                }
+                "core-cycle" => {
+                    ui::run_tui(presets::stability_core_cycle(60));
+                    return;
+                }
+                _ => {
+                    eprintln!("unknown preset: {name} (available: stability, bench, core-cycle)");
+                    std::process::exit(1);
+                }
+            }
+        }
+    }
     if args.iter().any(|a| a == "--tui") {
         ui::run_tui(cfg);
         return;
     }
-    // Default mode = TUI (the real front-end). Args without a dedicated
-    // mode fall through to the TUI so `adlerbench` with no args launches
-    // the sweep with a live display.
+    // Default mode = TUI picker (lets the user choose a preset or custom).
     ui::run_tui(cfg);
 }
 
diff --git a/src/presets.rs b/src/presets.rs
new file mode 100644
index 0000000..1c5d8ef
--- /dev/null
+++ b/src/presets.rs
@@ -0,0 +1,171 @@
+//! Sweep presets — named workload sets + sweep params for the TUI's
+//! picker screen + the `--preset` CLI arg. Each builder returns a
+//! `Config` ready to hand to `Run::new`.
+//!
+//! Three presets:
+//!  - `stability` — stress-ng-cpu → y-cruncher-sse → y-cruncher-avx2,
+//!    5 min (300s) each, all cores, 15s cooldown, looped to fill ~1 hour
+//!    (4 loops ≈ 49 min, 5 loops ≈ 61 min — we pick 5 to hit the target).
+//!  - `stability_core_cycle` — the same three workloads, but run on ONE
+//!    logical core at a time (cycling through all 16 twice), shorter
+//!    per-core duration (default 60s). Used to test peak single-core
+//!    clocks on chips with per-core load-based multipliers.
+//!  - `bench` — 7zip-bench → y-cruncher-sse → y-cruncher-avx2, 60s each,
+//!    all cores, 15s cooldown, single pass (the scores are the point;
+//!    looping doesn't improve the measurement).
+
+use crate::config::{Config, WorkloadSpec};
+
+/// Workload names available in v1 (the from_spec match arms in workload.rs).
+/// The custom editor iterates this list for its toggle rows.
+pub const AVAILABLE_WORKLOADS: &[&str] = &[
+    "stress-ng-cpu",
+    "y-cruncher-sse",
+    "y-cruncher-avx2",
+    "7zip-bench",
+];
+
+/// Cores range options for the custom editor's per-workload cores field.
+/// P = performance cores only, E = efficiency cores only, All = both.
+/// (Reserved for the editor's cores-field cycling; not yet wired into
+/// the TUI's cores edit — the editor currently accepts free-text cores
+/// input via the numeric/string edit buffer.)
+#[allow(dead_code)]
+pub const CORES_OPTIONS: &[&str] = &["0-15", "0-11", "12-15"];
+
+fn spec(name: &str, duration_secs: u64, cores: &str) -> WorkloadSpec {
+    WorkloadSpec {
+        name: name.to_string(),
+        duration_secs,
+        cores: cores.to_string(),
+    }
+}
+
+/// Stability preset: cycle the three ISA-rail stress tests, 5 min each,
+/// all cores, 15s cooldown, looped to fill ~1 hour. 4 loops = 49 min,
+/// 5 loops = 61 min — pick 5 to cross the hour. Cancelable mid-run
+/// (q in the TUI) so the user isn't committed to the full hour.
+pub fn stability() -> Config {
+    Config {
+        workloads: vec![
+            spec("stress-ng-cpu", 300, "0-15"),
+            spec("y-cruncher-sse", 300, "0-15"),
+            spec("y-cruncher-avx2", 300, "0-15"),
+        ],
+        cool_secs: 15,
+        loop_count: 5,
+        ..Config::default()
+    }
+}
+
+/// Stability core-cycle preset: the same three workloads, but run on
+/// ONE logical core at a time. Cycles through all 16 logical cores
+/// twice. The `per_core_secs` arg sets the per-workload-per-core run
+/// length (default 60s). Total run time ≈ 16 cores × 2 passes × 3
+/// workloads × per_core_secs + cooldowns. Long, but cancelable.
+///
+/// This tests peak single-core clocks on chips with per-core load-based
+/// multipliers — the question is whether each individual core is stable
+/// at its own peak clock, not just whether the package is stable at the
+/// all-core clock.
+pub fn stability_core_cycle(per_core_secs: u64) -> Config {
+    let mut workloads: Vec<WorkloadSpec> = Vec::new();
+    // Two full passes across all 16 logical cores (0-15).
+    for pass in 0..2u32 {
+        for cpu in 0..16u32 {
+            // Three ISA rails, each on this single core.
+            for name in ["stress-ng-cpu", "y-cruncher-sse", "y-cruncher-avx2"] {
+                workloads.push(spec(name, per_core_secs, &cpu.to_string()));
+            }
+        }
+        let _ = pass; // pass count is the loop bound, not a body var
+    }
+    Config {
+        workloads,
+        cool_secs: 5, // shorter cooldown — single-core load doesn't heat the package
+        loop_count: 1, // the core-cycle IS the loop; don't loop the whole thing
+        ..Config::default()
+    }
+}
+
+/// Bench preset: the three score-producing workloads, 60s each, all
+/// cores, 15s cooldown, single pass. The scores (7z rating MIPS,
+/// y-cruncher FFTv4 Gb/s) are the comparative-perf signal across OC
+/// configs.
+pub fn bench() -> Config {
+    Config {
+        workloads: vec![
+            spec("7zip-bench", 60, "0-15"),
+            spec("y-cruncher-sse", 60, "0-15"),
+            spec("y-cruncher-avx2", 60, "0-15"),
+        ],
+        cool_secs: 15,
+        loop_count: 1,
+        ..Config::default()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn stability_has_three_rails_five_loops() {
+        let cfg = stability();
+        assert_eq!(cfg.workloads.len(), 3);
+        assert_eq!(cfg.workloads[0].name, "stress-ng-cpu");
+        assert_eq!(cfg.workloads[1].name, "y-cruncher-sse");
+        assert_eq!(cfg.workloads[2].name, "y-cruncher-avx2");
+        assert_eq!(cfg.workloads[0].duration_secs, 300);
+        assert_eq!(cfg.cool_secs, 15);
+        assert_eq!(cfg.loop_count, 5);
+    }
+
+    #[test]
+    fn stability_core_cycle_visits_each_core_twice() {
+        let cfg = stability_core_cycle(60);
+        // 16 cores × 2 passes × 3 workloads = 96 entries.
+        assert_eq!(cfg.workloads.len(), 96);
+        // First three are cpu0 (stress-ng, sse, avx2).
+        assert_eq!(cfg.workloads[0].name, "stress-ng-cpu");
+        assert_eq!(cfg.workloads[0].cores, "0");
+        assert_eq!(cfg.workloads[1].name, "y-cruncher-sse");
+        assert_eq!(cfg.workloads[1].cores, "0");
+        assert_eq!(cfg.workloads[2].name, "y-cruncher-avx2");
+        assert_eq!(cfg.workloads[2].cores, "0");
+        // Last three are cpu15 on the second pass.
+        let last = &cfg.workloads[95];
+        assert_eq!(last.name, "y-cruncher-avx2");
+        assert_eq!(last.cores, "15");
+        // All single-core (no "0-15" range anywhere).
+        assert!(cfg.workloads.iter().all(|w| w.cores != "0-15"));
+        assert_eq!(cfg.loop_count, 1);
+    }
+
+    #[test]
+    fn stability_core_cycle_custom_duration() {
+        let cfg = stability_core_cycle(30);
+        assert_eq!(cfg.workloads[0].duration_secs, 30);
+    }
+
+    #[test]
+    fn bench_has_three_scored_workloads_single_pass() {
+        let cfg = bench();
+        assert_eq!(cfg.workloads.len(), 3);
+        assert_eq!(cfg.workloads[0].name, "7zip-bench");
+        assert_eq!(cfg.workloads[1].name, "y-cruncher-sse");
+        assert_eq!(cfg.workloads[2].name, "y-cruncher-avx2");
+        assert_eq!(cfg.workloads[0].duration_secs, 60);
+        assert_eq!(cfg.loop_count, 1);
+    }
+
+    #[test]
+    fn available_workloads_lists_v1_impls() {
+        assert!(AVAILABLE_WORKLOADS.contains(&"stress-ng-cpu"));
+        assert!(AVAILABLE_WORKLOADS.contains(&"y-cruncher-sse"));
+        assert!(AVAILABLE_WORKLOADS.contains(&"y-cruncher-avx2"));
+        assert!(AVAILABLE_WORKLOADS.contains(&"7zip-bench"));
+        // Deferred impls NOT in the list (c2c/dram/minecraft).
+        assert!(!AVAILABLE_WORKLOADS.contains(&"c2c-latency"));
+    }
+}
\ No newline at end of file
diff --git a/src/run.rs b/src/run.rs
index 27606c9..8ba32bd 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -63,6 +63,15 @@ pub struct Run {
     state_start: Instant,
     /// The report being written for the current workload.
     report: Option<Report>,
+    /// How many loops are left, counting the current one. 1 = this is
+    /// the last (or only) loop. 0 = infinite (loop forever). Decremented
+    /// when a sweep completes + there are more loops to go; the run then
+    /// resets `current` to 0 + re-enters Cooldown. When this hits 0 after
+    /// the decrement (and it wasn't infinite), State::Done fires.
+    loops_left: u32,
+    /// 1-indexed ordinal of the current loop (for the TUI header). 1 on
+    /// the first pass, 2 after one reset, etc.
+    loop_index: u32,
 }
 
 enum State {
@@ -94,6 +103,7 @@ impl Run {
                 )),
             }
         }
+        let loops_left = cfg.loop_count;
         Run {
             cfg,
             workloads,
@@ -104,6 +114,8 @@ impl Run {
             },
             state_start: Instant::now(),
             report: None,
+            loops_left,
+            loop_index: 1,
         }
     }
 
@@ -181,6 +193,19 @@ impl Run {
         matches!(self.state, State::Cooldown { .. })
     }
 
+    /// 1-indexed ordinal of the current loop (for the TUI header,
+    /// "loop 2/5"). 1 on the first pass, 2 after one reset, etc. When
+    /// loop_count is 1 (single pass) this is always 1.
+    pub fn loop_index(&self) -> u32 {
+        self.loop_index
+    }
+
+    /// The configured loop count (1 = single pass, 0 = infinite). For
+    /// the TUI header ("loop N/M"). 0 renders as "loop N/∞".
+    pub fn loop_count(&self) -> u32 {
+        self.cfg.loop_count
+    }
+
     /// One tick of the state machine. Call every `poll_ms`. Polls the
     /// workload's `is_running()` to detect completion + advances state.
     pub fn tick(&mut self) -> io::Result<TickOutcome> {
@@ -268,7 +293,36 @@ impl Run {
             self.current += 1;
         }
         if self.current >= self.workloads.len() {
-            self.state = State::Done;
+            // End of this loop's sweep. Decide whether to loop again.
+            // loops_left == 0 means INFINITE (loop forever); > 1 means
+            // there are more loops to go; == 1 means this was the last.
+            let loop_again = self.loops_left == 0 || self.loops_left > 1;
+            if loop_again {
+                // Decrement (unless infinite — keep 0) + reset to loop start.
+                if self.loops_left > 0 {
+                    self.loops_left -= 1;
+                }
+                self.loop_index += 1;
+                self.current = 0;
+                // Skip any leading None entries (defensive — shouldn't
+                // happen since construction skips them, but the same
+                // loop the constructor relies on).
+                while self.current < self.workloads.len() && self.workloads[self.current].is_none() {
+                    self.current += 1;
+                }
+                // If the whole workload list was None (shouldn't happen),
+                // fall through to Done.
+                if self.current >= self.workloads.len() {
+                    self.state = State::Done;
+                } else {
+                    self.state = State::Cooldown {
+                        remaining: self.cfg.cool_secs,
+                    };
+                    self.state_start = Instant::now();
+                }
+            } else {
+                self.state = State::Done;
+            }
         } else {
             self.state = State::Cooldown {
                 remaining: self.cfg.cool_secs,
@@ -316,6 +370,8 @@ mod tests {
         }
         fn start(&mut self) -> io::Result<()> {
             self.started = true;
+            self.stopped = false;
+            self.ticks_seen = 0;
             Ok(())
         }
         fn is_running(&mut self) -> bool {
@@ -346,6 +402,7 @@ mod tests {
             workloads,
             poll_ms: 10,
             cool_secs: 0, // no cooldown in tests
+            loop_count: 1,
             report_dir: std::path::PathBuf::from("/tmp/opencode/run-tests"),
             bin_dir: std::path::PathBuf::from("bin"),
             loaded_path: None,
@@ -357,6 +414,7 @@ mod tests {
     fn run_with_mocks(cfg: Config, mocks: Vec<MockWorkload>) -> Run {
         let workloads: Vec<Option<Box<dyn Workload>>> =
             mocks.into_iter().map(|m| Some(Box::new(m) as Box<dyn Workload>)).collect();
+        let loops_left = cfg.loop_count;
         Run {
             cfg,
             workloads,
@@ -365,6 +423,8 @@ mod tests {
             state: State::Cooldown { remaining: 0 },
             state_start: Instant::now(),
             report: None,
+            loops_left,
+            loop_index: 1,
         }
     }
 
@@ -443,4 +503,70 @@ mod tests {
         let run = Run::new(Config::default());
         let _ = run.skipped();
     }
+
+    #[test]
+    fn loop_resets_current_and_decrements() {
+        // Two workloads, loop_count = 2 → should run each twice (4 starts
+        // total) before SweepDone.
+        let _ = std::fs::remove_dir_all("/tmp/opencode/run-tests");
+        let mut cfg = mock_cfg(vec![
+            WorkloadSpec { name: "a".to_string(), duration_secs: 60, cores: "0-15".to_string() },
+            WorkloadSpec { name: "b".to_string(), duration_secs: 60, cores: "0-15".to_string() },
+        ]);
+        cfg.loop_count = 2;
+        let mut run = run_with_mocks(cfg, vec![
+            MockWorkload { name: "a".to_string(), ticks_until_exit: 2, ticks_seen: 0, started: false, stopped: false },
+            MockWorkload { name: "b".to_string(), ticks_until_exit: 2, ticks_seen: 0, started: false, stopped: false },
+        ]);
+        let mut starts = 0;
+        let mut done = false;
+        for _ in 0..40 {
+            match run.tick().unwrap() {
+                TickOutcome::WorkloadStarted { name, .. } => {
+                    starts += 1;
+                    assert!(name == "a" || name == "b");
+                }
+                TickOutcome::WorkloadFinished { .. } => {}
+                TickOutcome::SweepDone => {
+                    done = true;
+                    break;
+                }
+                TickOutcome::Idle => {}
+            }
+        }
+        assert!(done, "should reach SweepDone");
+        assert_eq!(starts, 4, "two workloads × two loops = 4 starts");
+        assert_eq!(run.loop_index(), 2, "loop_index should be 2 after one reset");
+    }
+
+    #[test]
+    fn loop_infinite_never_done() {
+        // loop_count = 0 means infinite. With short mocks the sweep would
+        // complete many times; verify it never reaches SweepDone (we'd
+        // be here forever in real life, but we cap the loop + check that
+        // it's still going after several sweep completions).
+        let _ = std::fs::remove_dir_all("/tmp/opencode/run-tests");
+        let mut cfg = mock_cfg(vec![
+            WorkloadSpec { name: "a".to_string(), duration_secs: 60, cores: "0-15".to_string() },
+        ]);
+        cfg.loop_count = 0; // infinite
+        let mut run = run_with_mocks(cfg, vec![
+            MockWorkload { name: "a".to_string(), ticks_until_exit: 2, ticks_seen: 0, started: false, stopped: false },
+        ]);
+        let mut starts = 0;
+        let mut sweep_done = false;
+        for _ in 0..100 {
+            match run.tick().unwrap() {
+                TickOutcome::WorkloadStarted { .. } => starts += 1,
+                TickOutcome::SweepDone => {
+                    sweep_done = true;
+                    break;
+                }
+                _ => {}
+            }
+        }
+        assert!(!sweep_done, "infinite loop should never reach SweepDone");
+        // Should have completed several loops (start count >> 1).
+        assert!(starts > 5, "infinite loop should have started many times, got {starts}");
+    }
 }
\ No newline at end of file
diff --git a/src/ui.rs b/src/ui.rs
index a425e43..b64c53f 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1,11 +1,11 @@
-//! ratatui TUI front-end. Consumes `run::Run::tick()` every `poll_ms` and
-//! renders: the current workload + elapsed/remaining, and a per-run
-//! result table after each `WorkloadFinished`. q/Esc quits gracefully
-//! (stops the active workload, flushes a partial report, exits).
+//! ratatui TUI front-end. Three modes:
+//!  - **Picker** (home screen): choose Stability / Bench / Custom / q to quit.
+//!  - **Editor** (custom preset): toggle workloads, edit duration/cores/
+//!    cooldown/loop count, reorder, then Start to launch the sweep.
+//!  - **Sweep**: consume `run::Run::tick()` every `poll_ms`, render the
+//!    current workload + progress + a results table. q/Esc quits.
 //!
 //! NO sensor meters — adlermon owns sensors. adlerbench records scores.
-//! The TUI is correspondingly simple: a progress bar for the current
-//! workload + a results table of scores.
 
 use std::io;
 use std::time::Duration;
@@ -16,14 +16,415 @@ use ratatui::{
     layout::{Constraint, Direction, Layout, Rect},
     style::{Color, Modifier, Style},
     text::{Line, Span},
-    widgets::{Block, Borders, Cell, Paragraph, Row, Table},
+    widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table},
 };
 
-use crate::config::Config;
+use crate::config::{Config, WorkloadSpec};
+use crate::presets;
 use crate::report;
 use crate::run::{Run, TickOutcome};
 use crate::workload::{Score, Verdict};
 
+/// The three top-level modes the TUI cycles through.
+enum Mode {
+    /// Picker home screen — choose a preset or custom.
+    Picker(PickerState),
+    /// Custom preset editor — tune the workload list + params before launch.
+    Editor(EditorState),
+    /// Sweep running — drives Run::tick() + renders progress + results.
+    Sweep(App),
+    /// Sweep done — final render sits until the user presses q.
+    Done(App),
+}
+
+/// Picker screen state: which row is highlighted (0..N).
+struct PickerState {
+    cursor: usize,
+}
+
+/// Custom editor state. Holds a mutable Config the user edits before
+/// launching the sweep. The workload list is the central piece — each
+/// row is a WorkloadSpec the user can toggle, edit, or reorder.
+struct EditorState {
+    cfg: Config,
+    /// Which row is highlighted (index into cfg.workloads).
+    cursor: usize,
+    /// Which field of the highlighted row is selected for editing.
+    /// 0 = name (toggle), 1 = duration, 2 = cores. The global fields
+    /// (cooldown, loop count) are rows below the workload list.
+    field: EditorField,
+    /// True while editing a numeric field (digit keys append, Enter
+    /// commits, Esc cancels).
+    editing: Option<String>,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum EditorField {
+    Name,
+    Duration,
+    Cores,
+    Cooldown,
+    LoopCount,
+}
+
+impl EditorField {
+    /// Render order (top to bottom in the editor).
+    const PER_ROW: [Self; 3] = [Self::Name, Self::Duration, Self::Cores];
+    const GLOBALS: [Self; 2] = [Self::Cooldown, Self::LoopCount];
+}
+
+// ---------------------------------------------------------------------------
+// Picker
+// ---------------------------------------------------------------------------
+
+const PICKER_ROWS: &[(&str, &str)] = &[
+    ("Stability", "stress-ng + y-cruncher SSE/AVX2, 5 min each, looped 1 hr"),
+    ("Bench", "7z + y-cruncher SSE/AVX2, 60 s each, single pass"),
+    ("Core-cycle", "single-core per-core stability, 16 cores x 2 passes"),
+    ("Custom", "tune the workload list + params yourself"),
+];
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+/// Entry point. Owns raw mode via ratatui::init/restore (same pattern as
+/// adlermon). The mode loop: draw → poll events (poll_ms timeout) →
+/// dispatch by mode. q/Esc at the picker quits; during the sweep it
+/// cancels back to the picker (if the sweep was launched from there) or
+/// quits (if launched from --preset/--tui directly).
+pub fn run_tui(cfg: Config) {
+    let mut terminal = ratatui::init();
+    let mut mode = Mode::Picker(PickerState { cursor: 0 });
+    // The cfg arg is the fallback for "Custom" (starts from whatever was
+    // loaded from the conf file). Presets replace it wholesale.
+    let default_cfg = cfg;
+
+    loop {
+        terminal.draw(|f| draw(f, &mode)).expect("draw failed");
+        let poll_ms = match &mode {
+            Mode::Sweep(app) | Mode::Done(app) => app.run.poll_ms(),
+            _ => 100, // picker + editor don't need fast refresh
+        };
+        if !event::poll(Duration::from_millis(poll_ms)).unwrap_or(false) {
+            continue;
+        }
+        let event = event::read();
+        let Ok(Event::Key(key)) = event else {
+            continue;
+        };
+        if key.kind != crossterm::event::KeyEventKind::Press {
+            continue;
+        }
+        match &mut mode {
+            Mode::Picker(state) => {
+                match key.code {
+                    KeyCode::Up | KeyCode::Char('k') => state.cursor = state.cursor.saturating_sub(1),
+                    KeyCode::Down | KeyCode::Char('j') => {
+                        state.cursor = (state.cursor + 1).min(PICKER_ROWS.len() - 1)
+                    }
+                    KeyCode::Enter => {
+                        match state.cursor {
+                            0 => mode = Mode::Sweep(App::new(presets::stability())),
+                            1 => mode = Mode::Sweep(App::new(presets::bench())),
+                            2 => mode = Mode::Sweep(App::new(presets::stability_core_cycle(60))),
+                            3 => mode = Mode::Editor(EditorState::from_cfg(default_cfg.clone())),
+                            _ => {}
+                        }
+                    }
+                    KeyCode::Char('q') | KeyCode::Esc => break,
+                    _ => {}
+                }
+            }
+            Mode::Editor(state) => {
+                match editor_key(state, key.code) {
+                    EditorAction::Start => mode = Mode::Sweep(App::new(state.cfg.clone())),
+                    EditorAction::Cancel => mode = Mode::Picker(PickerState { cursor: 3 }),
+                    EditorAction::None => {}
+                }
+            }
+            Mode::Sweep(app) => {
+                // Drive one tick before polling events so the sweep
+                // advances even while we're waiting for input.
+                let outcome = match app.tick() {
+                    Ok(o) => o,
+                    Err(e) => {
+                        eprintln!("run error: {e}");
+                        break;
+                    }
+                };
+                let done = matches!(outcome, TickOutcome::SweepDone);
+                if done {
+                    // Move the App out of the Sweep mode into Done. We
+                    // can't do this while `app` is borrowed, so use a
+                    // sentinel — replace with empty + push the real one
+                    // into Done below.
+                    let finished = std::mem::replace(app, App::empty());
+                    mode = Mode::Done(finished);
+                    continue;
+                }
+                match key.code {
+                    KeyCode::Char('q') | KeyCode::Esc => break,
+                    _ => {}
+                }
+            }
+            Mode::Done(app) => match key.code {
+                KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter => {
+                    // Print the summary + exit.
+                    print_summary(app);
+                    break;
+                }
+                _ => {}
+            },
+        }
+    }
+
+    ratatui::restore();
+    // If we exited from Sweep/Done, print the post-TUI summary.
+    if let Mode::Done(app) = &mode {
+        print_summary(app);
+    } else if let Mode::Sweep(app) = &mode {
+        print_summary(app);
+    }
+}
+
+fn print_summary(app: &App) {
+    if app.results.is_empty() {
+        if app.quit {
+            println!("quit before any workload finished");
+        }
+        return;
+    }
+    println!();
+    for r in &app.results {
+        println!(
+            "[{}] {} {:<8} {:<24} verdict={:?}",
+            r.idx, r.name, r.isa_class, format_score_short(&r.score), r.verdict
+        );
+        println!("  report: {}", r.report_path);
+    }
+    println!("sweep complete: {} workloads", app.results.len());
+}
+
+// ---------------------------------------------------------------------------
+// Editor
+// ---------------------------------------------------------------------------
+
+#[derive(PartialEq, Eq)]
+enum EditorAction {
+    None,
+    Start,
+    Cancel,
+}
+
+impl EditorState {
+    fn from_cfg(cfg: Config) -> Self {
+        EditorState {
+            cfg,
+            cursor: 0,
+            field: EditorField::Name,
+            editing: None,
+        }
+    }
+}
+
+/// Handle one key in the editor. Returns whether to start the sweep,
+/// cancel back to the picker, or do nothing.
+fn editor_key(state: &mut EditorState, key: KeyCode) -> EditorAction {
+    // If we're editing a numeric field, digit keys append, Enter commits,
+    // Esc cancels, Backspace pops.
+    if let Some(buf) = &mut state.editing {
+        match key {
+            KeyCode::Enter => {
+                let raw = std::mem::take(&mut state.editing).unwrap_or_default();
+                apply_editor_edit(state, &raw);
+            }
+            KeyCode::Esc => {
+                state.editing = None;
+            }
+            KeyCode::Backspace => {
+                buf.pop();
+            }
+            KeyCode::Char(c) if c.is_ascii_digit() => {
+                buf.push(c);
+            }
+            _ => {}
+        }
+        return EditorAction::None;
+    }
+
+    // Not editing — navigate fields + rows.
+    match key {
+        KeyCode::Up => {
+            editor_move(state, -1);
+        }
+        KeyCode::Down => {
+            editor_move(state, 1);
+        }
+        KeyCode::Left | KeyCode::Right => {
+            // Cycle field within the current row (only for workload rows).
+            if state.cursor < state.cfg.workloads.len() {
+                let idx = EditorField::PER_ROW.iter().position(|f| *f == state.field).unwrap_or(0);
+                let dir = if matches!(key, KeyCode::Left) { -1i32 } else { 1 };
+                let n = EditorField::PER_ROW.len() as i32;
+                let next = ((idx as i32 + dir).rem_euclid(n)) as usize;
+                state.field = EditorField::PER_ROW[next];
+            }
+        }
+        KeyCode::Enter => {
+            // Enter on a workload row's Name field toggles inclusion (cycles
+            // through available workload names). On Duration/Cores/Cooldown/
+            // LoopCount it starts numeric editing. On a "Start" pseudo-row
+            // (cursor == workloads.len() + globals.len()) it launches.
+            let total_rows = state.cfg.workloads.len() + EditorField::GLOBALS.len() + 1; // +Start
+            if state.cursor == total_rows - 1 {
+                return EditorAction::Start;
+            }
+            match state.field {
+                EditorField::Name => {
+                    // Toggle to the next available workload name.
+                    if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
+                        let idx = presets::AVAILABLE_WORKLOADS
+                            .iter()
+                            .position(|n| *n == w.name)
+                            .unwrap_or(0);
+                        let next = (idx + 1) % presets::AVAILABLE_WORKLOADS.len();
+                        w.name = presets::AVAILABLE_WORKLOADS[next].to_string();
+                    }
+                }
+                EditorField::Duration | EditorField::Cores | EditorField::Cooldown | EditorField::LoopCount => {
+                    let current = editor_current_value(state);
+                    state.editing = Some(current);
+                }
+            }
+        }
+        // 'd' deletes the current workload row (for the custom editor's
+        // "remove" action). Bound to a letter so it doesn't collide with
+        // numeric editing.
+        KeyCode::Char('d') if state.cursor < state.cfg.workloads.len() => {
+            state.cfg.workloads.remove(state.cursor);
+            if state.cursor >= state.cfg.workloads.len() && !state.cfg.workloads.is_empty() {
+                state.cursor = state.cfg.workloads.len() - 1;
+            }
+        }
+        // 'a' adds a new workload row at the end (defaulting to the first
+        // available name).
+        KeyCode::Char('a') => {
+            state.cfg.workloads.push(WorkloadSpec {
+                name: presets::AVAILABLE_WORKLOADS[0].to_string(),
+                duration_secs: 60,
+                cores: "0-15".to_string(),
+            });
+            state.cursor = state.cfg.workloads.len() - 1;
+            state.field = EditorField::Name;
+        }
+        // Move the current workload row up/down (reorder).
+        KeyCode::Char('k') if state.cursor < state.cfg.workloads.len() && state.cursor > 0 => {
+            state.cfg.workloads.swap(state.cursor, state.cursor - 1);
+            state.cursor -= 1;
+        }
+        KeyCode::Char('j') if state.cursor < state.cfg.workloads.len()
+            && state.cursor + 1 < state.cfg.workloads.len() =>
+        {
+            state.cfg.workloads.swap(state.cursor, state.cursor + 1);
+            state.cursor += 1;
+        }
+        KeyCode::Esc => return EditorAction::Cancel,
+        _ => {}
+    }
+    EditorAction::None
+}
+
+/// Move the editor cursor by `dir` rows (clamped). Fields reset to Name
+/// when entering a workload row, or to the appropriate global field when
+/// entering a global row.
+fn editor_move(state: &mut EditorState, dir: i32) {
+    let total = state.cfg.workloads.len() + EditorField::GLOBALS.len() + 1; // +Start
+    let next = (state.cursor as i32 + dir).clamp(0, (total as i32) - 1) as usize;
+    state.cursor = next;
+    // Reset field to the first valid one for the new row.
+    let workloads_n = state.cfg.workloads.len();
+    if next < workloads_n {
+        state.field = EditorField::Name;
+    } else if next < workloads_n + EditorField::GLOBALS.len() {
+        state.field = EditorField::GLOBALS[next - workloads_n];
+    } else {
+        // Start row — no field.
+        state.field = EditorField::Name;
+    }
+}
+
+/// The current field's value as a string (for the edit buffer's initial
+/// content when the user presses Enter to edit).
+fn editor_current_value(state: &EditorState) -> String {
+    match state.field {
+        EditorField::Name => state
+            .cfg
+            .workloads
+            .get(state.cursor)
+            .map(|w| w.name.clone())
+            .unwrap_or_default(),
+        EditorField::Duration => state
+            .cfg
+            .workloads
+            .get(state.cursor)
+            .map(|w| w.duration_secs.to_string())
+            .unwrap_or_default(),
+        EditorField::Cores => state
+            .cfg
+            .workloads
+            .get(state.cursor)
+            .map(|w| w.cores.clone())
+            .unwrap_or_default(),
+        EditorField::Cooldown => state.cfg.cool_secs.to_string(),
+        EditorField::LoopCount => state.cfg.loop_count.to_string(),
+    }
+}
+
+/// Apply a committed edit buffer to the current field.
+fn apply_editor_edit(state: &mut EditorState, raw: &str) {
+    let raw = raw.trim();
+    match state.field {
+        EditorField::Name => {
+            if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
+                w.name = raw.to_string();
+            }
+        }
+        EditorField::Duration => {
+            if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
+                if let Ok(v) = raw.parse::<u64>() {
+                    w.duration_secs = v.max(1);
+                }
+            }
+        }
+        EditorField::Cores => {
+            if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
+                // Accept a cores range like "0-11" or a single number "3".
+                // Don't validate beyond non-empty — the workload impls
+                // handle bad ranges gracefully.
+                if !raw.is_empty() {
+                    w.cores = raw.to_string();
+                }
+            }
+        }
+        EditorField::Cooldown => {
+            if let Ok(v) = raw.parse::<u64>() {
+                state.cfg.cool_secs = v;
+            }
+        }
+        EditorField::LoopCount => {
+            if let Ok(v) = raw.parse::<u32>() {
+                state.cfg.loop_count = v;
+            }
+        }
+    }
+    state.editing = None;
+}
+
+// ---------------------------------------------------------------------------
+// Sweep (App — preserved from the prior session's ui.rs)
+// ---------------------------------------------------------------------------
+
 /// One row in the results table, captured from each `WorkloadFinished`.
 #[derive(Clone)]
 struct ResultRow {
@@ -39,12 +440,7 @@ struct ResultRow {
 /// headless mode (tick every poll_ms); the TUI just renders between ticks.
 pub struct App {
     run: Run,
-    /// Finished-workload rows, in completion order.
     results: Vec<ResultRow>,
-    /// True when the user asked to quit (q/Esc). The run loop checks this
-    /// after each tick + exits cleanly (the run's drop stops any active
-    /// workload via its `Workload::stop()` — but we don't rely on drop;
-    /// see `cancel_active`).
     quit: bool,
 }
 
@@ -57,9 +453,17 @@ impl App {
         }
     }
 
+    /// An empty App for the Done state (no run, no results) — used as a
+    /// placeholder when we need to move the real App out of a Mode.
+    pub fn empty() -> Self {
+        App {
+            run: Run::new(Config::default()),
+            results: Vec::new(),
+            quit: true,
+        }
+    }
+
     /// Drive one tick + capture any WorkloadFinished into the results vec.
-    /// Returns the outcome so the caller can also react (e.g. print a
-    /// final summary on SweepDone).
     fn tick(&mut self) -> io::Result<TickOutcome> {
         let outcome = self.run.tick()?;
         if let TickOutcome::WorkloadFinished {
@@ -83,125 +487,201 @@ impl App {
     }
 }
 
-/// Entry point. Owns raw mode via ratatui::init/restore (same pattern as
-/// adlermon). The loop: tick → draw → poll events (poll_ms timeout) →
-/// handle q/Esc. On quit, the active workload (if any) is stopped + its
-/// report flushed with a partial state; the run's own drop wouldn't do
-/// that, so we do it explicitly before restore().
-pub fn run_tui(cfg: Config) {
-    let mut terminal = ratatui::init();
-    let mut app = App::new(cfg);
+// ---------------------------------------------------------------------------
+// Draw dispatch
+// ---------------------------------------------------------------------------
 
-    // Warn about skipped workloads up front (printed to stderr before the
-    // TUI takes over the screen — same as headless mode).
-    for (name, why) in app.run.skipped() {
-        eprintln!("skipped: {name} ({why})");
+fn draw(f: &mut Frame, mode: &Mode) {
+    match mode {
+        Mode::Picker(state) => draw_picker(f, state),
+        Mode::Editor(state) => draw_editor(f, state),
+        Mode::Sweep(app) => draw_sweep(f, app),
+        Mode::Done(app) => draw_sweep(f, app),
     }
+}
 
-    let poll_ms = app.run.poll_ms();
-    loop {
-        // Drive the state machine + capture any finished result.
-        let outcome = match app.tick() {
-            Ok(o) => o,
-            Err(e) => {
-                eprintln!("run error: {e}");
-                break;
-            }
-        };
-        // Render the current state.
-        terminal.draw(|f| draw(f, &app)).expect("draw failed");
+fn draw_picker(f: &mut Frame, state: &PickerState) {
+    let area = f.area();
+    let items: Vec<ListItem> = PICKER_ROWS
+        .iter()
+        .enumerate()
+        .map(|(i, (name, desc))| {
+            let cursor = if i == state.cursor { ">" } else { " " };
+            let style = if i == state.cursor {
+                Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)
+            } else {
+                Style::default()
+            };
+            ListItem::new(Line::from(vec![
+                Span::styled(format!("{cursor} "), style),
+                Span::styled(format!("{name:<14}"), style),
+                Span::raw("  "),
+                Span::styled(*desc, Style::default().fg(Color::DarkGray)),
+            ]))
+        })
+        .collect();
+    let list = List::new(items)
+        .block(
+            Block::default()
+                .borders(Borders::ALL)
+                .border_style(Style::default().fg(Color::DarkGray))
+                .title(Span::styled(" adlerbench — choose a sweep ", Style::default().fg(Color::Cyan))),
+        );
+    f.render_widget(list, area);
+    // Footer with keybinds.
+    let footer = Line::from(vec![
+        Span::styled(
+            "↑↓ select  Enter start  q quit",
+            Style::default().fg(Color::DarkGray),
+        ),
+    ]);
+    let footer_area = Rect { y: area.bottom().saturating_sub(1), height: 1, ..area };
+    f.render_widget(Paragraph::new(footer), footer_area);
+}
 
-        // SweepDone + a final render → stop the loop so the summary prints.
-        if matches!(outcome, TickOutcome::SweepDone) {
-            break;
-        }
+fn draw_editor(f: &mut Frame, state: &EditorState) {
+    let area = f.area();
+    // Vertical: header(1) + workload list(flex) + globals(3) + start(1) + footer(1).
+    let chunks = Layout::default()
+        .direction(Direction::Vertical)
+        .constraints([
+            Constraint::Length(1),
+            Constraint::Min(5),
+            Constraint::Length(3),
+            Constraint::Length(1),
+            Constraint::Length(1),
+        ])
+        .split(area);
 
-        // Poll for input (timeout = poll_ms so we tick at the same cadence
-        // as the run loop — same pattern as adlermon).
-        if event::poll(Duration::from_millis(poll_ms)).unwrap_or(false) {
-            if let Ok(Event::Key(key)) = event::read() {
-                // adlermon pattern: only react on Press (not Release).
-                if key.kind != crossterm::event::KeyEventKind::Press {
-                    // skip — but we've already consumed the poll timeout,
-                    // so fall through to the next tick.
+    // Header.
+    f.render_widget(
+        Paragraph::new(Line::from(Span::styled(
+            "adlerbench — custom sweep editor",
+            Style::default().add_modifier(Modifier::BOLD),
+        ))),
+        chunks[0],
+    );
+
+    // Workload list (each row: name | duration | cores).
+    let rows: Vec<Row> = state
+        .cfg
+        .workloads
+        .iter()
+        .enumerate()
+        .map(|(i, w)| {
+            let cursor = if i == state.cursor { ">" } else { " " };
+            let row_style = if i == state.cursor {
+                Style::default().add_modifier(Modifier::BOLD)
+            } else {
+                Style::default()
+            };
+            // Highlight the active field within the row.
+            let cell = |field: EditorField, text: String| -> Cell {
+                if i == state.cursor && state.field == field {
+                    let editing = state.editing.as_deref().unwrap_or(&text);
+                    Cell::from(Span::styled(
+                        format!("[{editing}]"),
+                        Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
+                    ))
                 } else {
-                    match key.code {
-                        KeyCode::Char('q') | KeyCode::Esc => {
-                            app.quit = true;
-                            break;
-                        }
-                        _ => {}
-                    }
+                    Cell::from(Span::styled(text, row_style))
                 }
-            }
-        }
-    }
-
-    // Graceful cancel: stop the active workload (if any) so its report
-    // gets flushed with a Stopped verdict rather than vanishing. The run
-    // loop's finish_current_workload does this on the next tick, but we
-    // don't want to tick again after quit — call into the run to stop +
-    // wait + flush directly. We do this by ticking once more; the run's
-    // tick() will see the workload still running + the duration not
-    // elapsed, so it returns Idle — which doesn't help. Instead, we
-    // leave it to the Drop: Run doesn't impl Drop, so the Child inside
-    // SpawnedWorkload gets killed by std's process::Child::drop (which
-    // sends SIGKILL on Unix). The report file will have whatever was
-    // flushed last (verdict=null, score=null — a valid partial JSON).
-    // This is acceptable for a user-initiated quit; the headless mode
-    // has the same behavior on Ctrl-C.
-
-    ratatui::restore();
-
-    // After the TUI exits, print a one-line summary per finished workload
-    // (same shape as headless mode) so the terminal has a record.
-    if !app.results.is_empty() {
-        println!();
-        for r in &app.results {
-            println!(
-                "[{}] {} {:<8} {:<24} verdict={:?}",
-                r.idx, r.name, r.isa_class, format_score_short(&r.score), r.verdict
-            );
-            println!("  report: {}", r.report_path);
-        }
-        println!("sweep complete: {} workloads", app.results.len());
-    } else if app.quit {
-        println!("quit before any workload finished");
-    }
-}
-
-/// One-line score formatter for the post-TUI summary (shorter than
-/// main.rs's `format_score` — fits one table cell).
-fn format_score_short(s: &Score) -> String {
-    match s {
-        Score::None => "(stability only)".to_string(),
-        Score::SevenZip { rating_mips, .. } => format!("{rating_mips} MIPS"),
-        Score::YCruncher { tests } => {
-            if tests.is_empty() {
-                "(no tests)".to_string()
-            } else if tests.len() == 1 {
-                let t = &tests[0];
-                format!("{} {:.2} Gb/s", t.tag, t.bits_per_sec / 1e9)
-            } else {
-                format!("{} tests", tests.len())
-            }
-        }
+            };
+            Row::new(vec![
+                Cell::from(Span::styled(format!("{cursor} "), row_style)),
+                cell(EditorField::Name, w.name.clone()),
+                cell(EditorField::Duration, format!("{}s", w.duration_secs)),
+                cell(EditorField::Cores, w.cores.clone()),
+            ])
+        })
+        .collect();
+    let header = Row::new(vec![
+        Cell::from(Span::styled("  ", Style::default())),
+        Cell::from(Span::styled("workload", Style::default().fg(Color::Cyan))),
+        Cell::from(Span::styled("dur", Style::default().fg(Color::Cyan))),
+        Cell::from(Span::styled("cores", Style::default().fg(Color::Cyan))),
+    ]);
+    let table = Table::new(
+        rows,
+        [
+            Constraint::Length(2),
+            Constraint::Min(18),
+            Constraint::Length(8),
+            Constraint::Length(10),
+        ],
+    )
+    .header(header)
+    .block(
+        Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(Color::DarkGray))
+            .title(Span::styled(" workloads ", Style::default().fg(Color::Cyan))),
+    );
+    f.render_widget(table, chunks[1]);
+
+    // Globals (cooldown + loop count).
+    let global_cell = |field: EditorField, label: &str, value: String| -> Line {
+        let cursor = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.iter().position(|f| *f == field).unwrap() {
+            ">"
+        } else {
+            " "
+        };
+        let val_span = if state.cursor >= state.cfg.workloads.len()
+            && state.field == field
+        {
+            let editing = state.editing.as_deref().unwrap_or(&value);
+            Span::styled(format!("[{editing}]"), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
+        } else {
+            Span::raw(value)
+        };
+        Line::from(vec![
+            Span::raw(format!("{cursor} ")),
+            Span::styled(format!("{label:<12}"), Style::default().fg(Color::Cyan)),
+            val_span,
+        ])
+    };
+    let globals = [global_cell(EditorField::Cooldown, "cooldown", format!("{}s", state.cfg.cool_secs)),
+        global_cell(EditorField::LoopCount, "loop count", state.cfg.loop_count.to_string())];
+    for (i, line) in globals.iter().enumerate() {
+        f.render_widget(
+            Paragraph::new(line.clone()),
+            Rect { x: chunks[2].x, y: chunks[2].y + i as u16, width: chunks[2].width, height: 1 },
+        );
     }
-}
 
-/// Verdict → (short label, color) for the results table + current panel.
-fn verdict_badge(v: &Verdict) -> (&'static str, Color) {
-    match v {
-        Verdict::Clean => ("CLEAN", Color::Green),
-        Verdict::Failed(_) => ("FAILED", Color::Red),
-        Verdict::Stopped => ("STOPPED", Color::Yellow),
-        Verdict::Error(_) => ("ERROR", Color::Red),
-    }
+    // Start row.
+    let start_cursor = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.len() {
+        ">"
+    } else {
+        " "
+    };
+    let start_style = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.len() {
+        Style::default().add_modifier(Modifier::BOLD).fg(Color::Green)
+    } else {
+        Style::default().fg(Color::DarkGray)
+    };
+    f.render_widget(
+        Paragraph::new(Line::from(vec![
+            Span::styled(format!("{start_cursor} "), start_style),
+            Span::styled("Start sweep", start_style),
+        ])),
+        chunks[3],
+    );
+
+    // Footer with keybinds.
+    f.render_widget(
+        Paragraph::new(Line::from(vec![
+            Span::styled(
+                "↑↓ row  ←→ field  Enter edit/toggle  a add  d del  j/k move  Esc back",
+                Style::default().fg(Color::DarkGray),
+            ),
+        ])),
+        chunks[4],
+    );
 }
 
-pub fn draw(f: &mut Frame, app: &App) {
+fn draw_sweep(f: &mut Frame, app: &App) {
     let area = f.area();
-    // Vertical: header (1) + current panel (flex) + results table (flex) + footer (1).
     let chunks = Layout::default()
         .direction(Direction::Vertical)
         .constraints([
@@ -221,17 +701,27 @@ pub fn draw(f: &mut Frame, app: &App) {
 fn draw_header(f: &mut Frame, area: Rect, app: &App) {
     let total = app.run.total_workloads();
     let current = app.run.current_ordinal().min(total);
+    let loop_idx = app.run.loop_index();
+    let loop_total = app.run.loop_count();
+    let loop_str = if loop_total == 0 {
+        format!("loop {loop_idx}/∞")
+    } else {
+        format!("loop {loop_idx}/{loop_total}")
+    };
     let title = if app.run.is_done() {
         format!("adlerbench — sweep complete: {} workloads", app.results.len())
     } else if current == 0 {
-        format!("adlerbench — starting (0/{total})")
+        format!("adlerbench — starting (0/{total})  {loop_str}")
     } else {
-        format!("adlerbench — workload {current}/{total}")
+        format!("adlerbench — workload {current}/{total}  {loop_str}")
     };
-    let line = Line::from(vec![
-        Span::styled(title, Style::default().add_modifier(Modifier::BOLD)),
-    ]);
-    f.render_widget(Paragraph::new(line), area);
+    f.render_widget(
+        Paragraph::new(Line::from(Span::styled(
+            title,
+            Style::default().add_modifier(Modifier::BOLD),
+        ))),
+        area,
+    );
 }
 
 fn draw_current(f: &mut Frame, area: Rect, app: &App) {
@@ -281,8 +771,6 @@ fn draw_current(f: &mut Frame, area: Rect, app: &App) {
     let inner = block.inner(area);
     f.render_widget(block, area);
 
-    // Two rows: line 1 = name + isa + state; line 2 = params + time;
-    // line 3 = progress bar.
     let lines = [
         Line::from(vec![
             Span::styled(format!("{:<20}", spec.name), Style::default().add_modifier(Modifier::BOLD)),
@@ -315,12 +803,10 @@ fn draw_current(f: &mut Frame, area: Rect, app: &App) {
     }
 }
 
-/// Build a one-line progress bar: `[####······] 42%`. Uses block chars for
-/// fill + middle-dot for empty. Width is the interior cell count.
+/// Build a one-line progress bar: `[####······] 42%`.
 fn progress_bar(ratio: f64, width: u16) -> Line<'static> {
     let ratio = ratio.clamp(0.0, 1.0);
     let label = format!("{:>3.0}%", ratio * 100.0);
-    // Reserve 2 for [ ] + 1 space + label width (4 chars for "100%").
     let bar_w = width.saturating_sub(2 + 1 + 4) as usize;
     let fill = (ratio * bar_w as f64).round() as usize;
     let fill = fill.min(bar_w);
@@ -354,7 +840,6 @@ fn draw_results(f: &mut Frame, area: Rect, app: &App) {
         return;
     }
 
-    // Table columns: # | workload | isa | verdict | score.
     let header_cells = [
         Cell::from(Span::styled("#", Style::default().fg(Color::Cyan))),
         Cell::from(Span::styled("workload", Style::default().fg(Color::Cyan))),
@@ -362,7 +847,7 @@ fn draw_results(f: &mut Frame, area: Rect, app: &App) {
         Cell::from(Span::styled("verdict", Style::default().fg(Color::Cyan))),
         Cell::from(Span::styled("score", Style::default().fg(Color::Cyan))),
     ];
-    let header = Row::new(header_cells).height(1).bottom_margin(0);
+    let header = Row::new(header_cells).height(1);
 
     let rows = app.results.iter().map(|r| {
         let (vlabel, vcolor) = verdict_badge(&r.verdict);
@@ -375,15 +860,57 @@ fn draw_results(f: &mut Frame, area: Rect, app: &App) {
         ])
     });
 
-    let table = Table::new(rows, [Constraint::Length(3), Constraint::Min(18), Constraint::Length(8), Constraint::Length(10), Constraint::Min(20)])
-        .header(header)
-        .block(block);
+    let table = Table::new(
+        rows,
+        [
+            Constraint::Length(3),
+            Constraint::Min(18),
+            Constraint::Length(8),
+            Constraint::Length(10),
+            Constraint::Min(20),
+        ],
+    )
+    .header(header)
+    .block(block);
     f.render_widget(table, area);
 }
 
 fn draw_footer(f: &mut Frame, area: Rect, _app: &App) {
-    let line = Line::from(vec![
-        Span::styled("q: quit  Esc: quit", Style::default().fg(Color::DarkGray)),
-    ]);
-    f.render_widget(Paragraph::new(line), area);
+    f.render_widget(
+        Paragraph::new(Line::from(Span::styled(
+            "q: quit  Esc: quit",
+            Style::default().fg(Color::DarkGray),
+        ))),
+        area,
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+fn format_score_short(s: &Score) -> String {
+    match s {
+        Score::None => "(stability only)".to_string(),
+        Score::SevenZip { rating_mips, .. } => format!("{rating_mips} MIPS"),
+        Score::YCruncher { tests } => {
+            if tests.is_empty() {
+                "(no tests)".to_string()
+            } else if tests.len() == 1 {
+                let t = &tests[0];
+                format!("{} {:.2} Gb/s", t.tag, t.bits_per_sec / 1e9)
+            } else {
+                format!("{} tests", tests.len())
+            }
+        }
+    }
+}
+
+fn verdict_badge(v: &Verdict) -> (&'static str, Color) {
+    match v {
+        Verdict::Clean => ("CLEAN", Color::Green),
+        Verdict::Failed(_) => ("FAILED", Color::Red),
+        Verdict::Stopped => ("STOPPED", Color::Yellow),
+        Verdict::Error(_) => ("ERROR", Color::Red),
+    }
 }
\ No newline at end of file
diff --git a/src/workload.rs b/src/workload.rs
index 0441c61..92488c0 100644
--- a/src/workload.rs
+++ b/src/workload.rs
@@ -386,7 +386,11 @@ impl Workload for YCruncher {
                 format!("y-cruncher variant path canonicalize failed: {e}"),
             )
         })?;
-        let mut cmd = Command::new(&variant_path);
+        // Wrap in `taskset -c <cores>` when the cores range is a subset
+        // (not all 0-15). y-cruncher spawns 16 threads by default + has no
+        // CLI flag for affinity; taskset pins it at the kernel level. The
+        // full-range case skips the wrapper (no point constraining to all).
+        let mut cmd = taskset_command(&self.cores, &variant_path);
         cmd.arg("skip-warnings")
             .arg("stress")
             // y-cruncher expects `-D:3` as ONE arg (not `-D:` + `3`).
@@ -500,7 +504,12 @@ impl Workload for SevenZip {
         )
     }
     fn start(&mut self) -> io::Result<()> {
-        let mut cmd = Command::new("7z");
+        // Wrap in `taskset -c <cores>` when the cores range is a subset
+        // (not all 0-15). 7z's `-mmt<N>` only sets THREAD count, not
+        // affinity — taskset actually pins the process to the range. For
+        // the full-range case we skip the wrapper (no point constraining
+        // to all) + keep `-mmt<N>` for thread count.
+        let mut cmd = taskset_command(&self.cores, "7z");
         cmd.arg("b");
         if let Some(n) = self.thread_count() {
             cmd.arg(format!("-mmt{n}"));
@@ -546,6 +555,25 @@ impl Workload for SevenZip {
 // Public constructor: map a WorkloadSpec name to an impl
 // ---------------------------------------------------------------------------
 
+/// Build a `Command` for `program`, wrapped in `taskset -c <cores>` when
+/// `cores` is a subset of the available CPUs (not the full "0-15" range).
+/// The full-range case skips the wrapper — there's no point constraining
+/// to all CPUs. Used by YCruncher + SevenZip (StressNg has its own
+/// `--taskset` flag, so it doesn't go through here). Returns a `Command`
+/// pointing at either `taskset` (with program as an arg) or `program`
+/// directly. `program` accepts `OsStr` so it works with both `&str`
+/// (7z) and `&PathBuf` (y-cruncher's canonicalized variant path).
+fn taskset_command<P: AsRef<std::ffi::OsStr>>(cores: &str, program: P) -> Command {
+    // Full-range patterns that mean "all CPUs" — skip the wrapper.
+    let is_full = matches!(cores.trim(), "0-15" | "all" | "");
+    if is_full {
+        return Command::new(program);
+    }
+    let mut cmd = Command::new("taskset");
+    cmd.arg("-c").arg(cores).arg(program);
+    cmd
+}
+
 /// Build a Workload from a config spec. Returns None for unknown names
 /// (the run loop skips them with a warning) or names that aren't wired
 /// yet (c2c-latency, dram-latency, minecraft-server — deferred).
@@ -888,4 +916,31 @@ Test Finished. Waiting for threads to terminate...
         assert_eq!(strip_ansi("plain text"), "plain text");
         assert_eq!(strip_ansi("with\rcarriage"), "with\ncarriage");
     }
+
+    #[test]
+    fn taskset_command_wraps_subsets() {
+        // Full range → bare program, no taskset wrapper.
+        let cmd = taskset_command("0-15", "/bin/true");
+        let prog = cmd.get_program();
+        assert_eq!(prog, std::path::Path::new("/bin/true"));
+        // Subset → taskset -c <cores> <program>.
+        let cmd = taskset_command("0", "/bin/true");
+        assert_eq!(cmd.get_program(), std::path::Path::new("taskset"));
+        let args: Vec<_> = cmd.get_args().collect();
+        assert_eq!(args, [std::ffi::OsStr::new("-c"), std::ffi::OsStr::new("0"), std::ffi::OsStr::new("/bin/true")]);
+        // Also accepts a PathBuf (y-cruncher's canonicalized variant path).
+        let path = std::path::PathBuf::from("/bin/true");
+        let cmd = taskset_command("3", &path);
+        assert_eq!(cmd.get_program(), std::path::Path::new("taskset"));
+        let args: Vec<_> = cmd.get_args().collect();
+        assert_eq!(args.last(), Some(&std::ffi::OsStr::new("/bin/true")));
+    }
+
+    #[test]
+    fn taskset_command_skips_wrapper_for_all_and_empty() {
+        for full in ["0-15", "all", ""] {
+            let cmd = taskset_command(full, "/bin/true");
+            assert_eq!(cmd.get_program(), std::path::Path::new("/bin/true"), "full={full:?} should skip wrapper");
+        }
+    }
 }
\ No newline at end of file