diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs
--- a/crates/engine/src/cmd/midi.rs
+++ b/crates/engine/src/cmd/midi.rs
@@ -124,6 +124,12 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
     let dt = i64_or(p, "ticks", 0);
     let ds = i64_or(p, "semitones", 0);
     let seq = seq_mut(e, id, "midi.notes_move")?;
+    // Clamp the shared delta so the earliest selected note stops at tick 0 and the selection keeps its spacing.
+    let earliest = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| n.start).min();
+    let dt = match earliest {
+        Some(m) => dt.max(0i64.saturating_sub(m).min(0)),
+        None => dt,
+    };
     for (i, n) in seq.notes.iter_mut().enumerate() {
         if idx.contains(&i) {
             n.start = n.start.saturating_add(dt).max(0);
@@ -168,6 +174,36 @@ mod tests {
         assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err());
     }
 
+    #[test]
+    fn notes_move_left_past_zero_keeps_spacing() {
+        let mut e = Engine::default();
+        let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
+        let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap();
+        let clip = r["clip"].as_u64().unwrap();
+        for s in [100, 200, 300] {
+            e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap();
+        }
+        e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": -150})).unwrap();
+        let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
+        let starts: Vec<i64> = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect();
+        assert_eq!(starts, [0, 100, 300]);
+    }
+
+    #[test]
+    fn notes_move_without_left_overflow_does_not_shift_selection() {
+        let mut e = Engine::default();
+        let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
+        let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap();
+        for s in [0, 100] {
+            e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap();
+        }
+        seq_mut(&mut e, ClipId(clip), "test").unwrap().notes[0].start = -50;
+        e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": 0})).unwrap();
+        let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
+        let starts: Vec<i64> = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect();
+        assert_eq!(starts, [0, 100]);
+    }
+
     #[test]
     fn notes_move_huge_params_do_not_overflow() {
         let mut e = Engine::default();
